diff --git a/backend/src/domain/deals.test.ts b/backend/src/domain/deals.test.ts new file mode 100644 index 0000000..9ffd609 --- /dev/null +++ b/backend/src/domain/deals.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { dealOverAt } from './deals.ts'; + +const at = (iso: string) => new Date(iso); +const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' }; + +describe('dealOverAt', () => { + it('keeps the end of a deal that ends before the closing', () => { + const deal = { startsAt: at('2026-09-27T15:00:00Z'), endsAt: at('2026-09-27T17:00:00Z') }; + expect(dealOverAt(deal, ZERNO)).toEqual(deal.endsAt); + }); + + it('ends a deal at the closing when its end time is later', () => { + const deal = { startsAt: at('2026-09-27T18:48:00Z'), endsAt: at('2026-09-27T19:50:00Z') }; + expect(dealOverAt(deal, ZERNO)).toEqual(at('2026-09-27T19:00:00Z')); + }); + + it('ends a deal that starts while the venue is closed right at its start', () => { + const afterClosing = { startsAt: at('2026-09-27T19:30:00Z'), endsAt: at('2026-09-27T20:30:00Z') }; + expect(dealOverAt(afterClosing, ZERNO)).toEqual(afterClosing.startsAt); + const beforeOpening = { startsAt: at('2026-09-27T04:00:00Z'), endsAt: at('2026-09-27T19:00:00Z') }; + expect(dealOverAt(beforeOpening, ZERNO)).toEqual(beforeOpening.startsAt); + }); + + it('ends at a closing after midnight', () => { + const deal = { startsAt: at('2026-09-27T20:30:00Z'), endsAt: at('2026-09-28T01:00:00Z') }; + const bar = { ...ZERNO, opensAt: '18:00', closesAt: '02:00' }; + expect(dealOverAt(deal, bar)).toEqual(at('2026-09-27T23:00:00Z')); + }); + + it('has no closing round the clock', () => { + const deal = { startsAt: at('2026-09-27T21:30:00Z'), endsAt: at('2026-09-28T20:00:00Z') }; + expect(dealOverAt(deal, { ...ZERNO, opensAt: '00:00', closesAt: '00:00' })).toEqual(deal.endsAt); + }); +}); diff --git a/backend/src/domain/deals.ts b/backend/src/domain/deals.ts new file mode 100644 index 0000000..75857e8 --- /dev/null +++ b/backend/src/domain/deals.ts @@ -0,0 +1,13 @@ +import { isOpenAt, nextClosingAt } from '../shared/time.ts'; +import type { Deal, Venue } from './models.ts'; + +export function dealOverAt( + deal: Pick, + venue: Pick, +): Date { + const { opensAt, closesAt, timezone } = venue; + const closing = nextClosingAt(opensAt, closesAt, deal.startsAt, timezone); + if (!closing) return deal.endsAt; + if (!isOpenAt(opensAt, closesAt, deal.startsAt, timezone)) return deal.startsAt; + return closing < deal.endsAt ? closing : deal.endsAt; +} diff --git a/backend/src/http/routes/bookings.ts b/backend/src/http/routes/bookings.ts index d00ecbb..0329557 100644 --- a/backend/src/http/routes/bookings.ts +++ b/backend/src/http/routes/bookings.ts @@ -43,7 +43,7 @@ export const bookingsRoutes: FastifyPluginCallbackZod<{ bookings: BookingsServic 'deal_not_found (404): горящего предложения нет или оно на другую позицию.', 'offer_not_found (404): предложение показано другому гостю или на другую позицию, бронируйте без offerId.', 'venue_closed (409): заведение сейчас закрыто, покажите часы работы.', - 'deal_not_active (409): горящее предложение снято, закончилось или ещё не началось, предложите бронь по цене меню без dealId.', + 'deal_not_active (409): горящее предложение снято, закончилось (в том числе потому что заведение закрылось) или ещё не началось, предложите бронь по цене меню без dealId.', 'booking_exists (409): у гостя уже есть активная бронь этой позиции, откройте её из GET /api/v1/bookings.', 'deal_sold_out (409): порции по предложению закончились, предложите бронь по цене меню без dealId.', `too_many_bookings (409): уже ${MAX_ACTIVE_BOOKINGS} активные брони, отмените одну из них или дождитесь, пока заведение её погасит.`, diff --git a/backend/src/http/schemas/deals.ts b/backend/src/http/schemas/deals.ts index 69c802c..c469d53 100644 --- a/backend/src/http/schemas/deals.ts +++ b/backend/src/http/schemas/deals.ts @@ -17,7 +17,7 @@ export const DealSchema = z status: z .enum(DEAL_STATUSES) .describe( - 'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло; cancelled - снята заведением', + 'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло или заведение закрылось после начала предложения; cancelled - снята заведением', ), }) .meta({ diff --git a/backend/src/repositories/deals.ts b/backend/src/repositories/deals.ts index 4664da9..0a360ef 100644 --- a/backend/src/repositories/deals.ts +++ b/backend/src/repositories/deals.ts @@ -19,8 +19,24 @@ const DEAL_COLUMNS = `d.id, d.venue_id, d.menu_item_id, d.price_rub, d.quantity_ const LIVE = 'd.cancelled_at is null and d.quantity_left > 0'; +const LOCAL_START = '(d.starts_at at time zone v.timezone)'; + +const OPEN_AT_START = `(v.opens_at < v.closes_at and ${LOCAL_START}::time >= v.opens_at and ${LOCAL_START}::time < v.closes_at + or v.opens_at > v.closes_at and (${LOCAL_START}::time >= v.opens_at or ${LOCAL_START}::time < v.closes_at))`; + +const CLOSING_AFTER_START = `((${LOCAL_START}::date + v.closes_at + + case when ${LOCAL_START}::time < v.closes_at then interval '0 days' else interval '1 day' end) at time zone v.timezone)`; + +const OVER_AT = `case + when v.opens_at = v.closes_at then d.ends_at + when ${OPEN_AT_START} then least(d.ends_at, ${CLOSING_AFTER_START}) + else d.starts_at + end`; + +const WITH_VENUE = 'join venues v on v.id = d.venue_id'; + function visibleTo(nowParam: string): string { - return `${LIVE} and d.starts_at <= ${nowParam} and d.ends_at > ${nowParam} + return `${LIVE} and d.starts_at <= ${nowParam} and ${OVER_AT} > ${nowParam} and m.archived_at is null and m.is_available`; } @@ -107,7 +123,8 @@ export async function cancelLiveInVenue( ): Promise { await db.query( `update deals as d set cancelled_at = $3 - where d.venue_id = $1 and d.id = $2 and ${LIVE} and d.ends_at > $3`, + from venues v + where v.id = d.venue_id and d.venue_id = $1 and d.id = $2 and ${LIVE} and ${OVER_AT} > $3`, [venueId, id, now], ); } @@ -115,7 +132,8 @@ export async function cancelLiveInVenue( export async function cancelLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise { await db.query( `update deals as d set cancelled_at = $2 - where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2`, + from venues v + where v.id = d.venue_id and d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2`, [menuItemId, now], ); } @@ -141,8 +159,8 @@ export async function lockInVenue(db: Queryable, venueId: number, id: number): P export async function findLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise { const row = await maybeOne( db, - `select ${DEAL_COLUMNS} from deals d - where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2 + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} + where d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2 order by d.id limit 1`, [menuItemId, now], @@ -152,8 +170,8 @@ export async function findLiveForItem(db: Queryable, menuItemId: number, now: Da export async function listLive(db: Queryable, venueId: number, now: Date): Promise { const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d - where d.venue_id = $1 and ${LIVE} and d.ends_at > $2 + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} + where d.venue_id = $1 and ${LIVE} and ${OVER_AT} > $2 order by d.ends_at, d.id`, [venueId, now], ); @@ -162,9 +180,9 @@ export async function listLive(db: Queryable, venueId: number, now: Date): Promi export async function listFinished(db: Queryable, venueId: number, now: Date, since: Date): Promise { const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} where d.venue_id = $1 and d.created_at >= $3 - and (d.cancelled_at is not null or d.quantity_left = 0 or d.ends_at <= $2) + and (d.cancelled_at is not null or d.quantity_left = 0 or ${OVER_AT} <= $2) order by d.ends_at desc, d.id desc`, [venueId, now, since], ); @@ -174,7 +192,7 @@ export async function listFinished(db: Queryable, venueId: number, now: Date, si export async function listVisible(db: Queryable, venueIds: readonly number[], now: Date): Promise { if (venueIds.length === 0) return []; const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')} order by d.ends_at, d.id`, @@ -186,7 +204,7 @@ export async function listVisible(db: Queryable, venueIds: readonly number[], no export async function findVisible(db: Queryable, id: number, now: Date): Promise { const row = await maybeOne( db, - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.id = $1 and ${visibleTo('$2')}`, [id, now], @@ -201,7 +219,7 @@ export async function countVisibleByVenue( ): Promise> { if (venueIds.length === 0) return new Map(); const { rows } = await db.query<{ venue_id: number; deals: number }>( - `select d.venue_id, count(*) as deals from deals d + `select d.venue_id, count(*) as deals from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')} group by d.venue_id`, diff --git a/backend/src/services/bookings.int.test.ts b/backend/src/services/bookings.int.test.ts index 2406897..5b94f61 100644 --- a/backend/src/services/bookings.int.test.ts +++ b/backend/src/services/bookings.int.test.ts @@ -208,10 +208,24 @@ describe('creating bookings', () => { code: 'menu_item_not_found', }); clock.set('2026-09-25T19:00:00Z'); - await expect(service.create(GUEST, { menuItemId: eclair.id, dealId: deal.id })).rejects.toMatchObject({ + await expect(service.create(GUEST, { menuItemId: eclair.id })).rejects.toMatchObject({ status: 409, code: 'venue_closed', }); + }); + + it('says that a deal is over once the venue closes, even before its end time', async () => { + clock.set('2026-09-25T18:48:00Z'); + const late = await seedDeal(pool, tart, { startsAt: clock.now(), endsAt: later(HOUR) }); + clock.set('2026-09-25T19:05:00Z'); + for (const dealId of [late.id, deal.id]) { + const menuItemId = dealId === late.id ? tart.id : eclair.id; + await expect(service.create(GUEST, { menuItemId, dealId })).rejects.toMatchObject({ + status: 409, + code: 'deal_not_active', + }); + } + expect(await quantityLeft(late.id)).toBe(5); expect(await quantityLeft(deal.id)).toBe(2); }); diff --git a/backend/src/services/bookings.ts b/backend/src/services/bookings.ts index 8e793fb..381d1ac 100644 --- a/backend/src/services/bookings.ts +++ b/backend/src/services/bookings.ts @@ -8,6 +8,7 @@ import { normalizeBookingCode, qrPayload, } from '../domain/bookings.ts'; +import { dealOverAt } from '../domain/deals.ts'; import type { Booking, Deal, MenuItem, Venue } from '../domain/models.ts'; import type { BookingNotice, Notifier } from '../ports/notifier.ts'; import * as bookings from '../repositories/bookings.ts'; @@ -81,8 +82,8 @@ const bookingNotFound = () => notFound('booking_not_found', 'Booking not found') const bookingExpired = () => conflict('booking_expired', 'The booking has expired, the portion is released'); const bookingNotActive = () => conflict('booking_not_active', 'The booking is already redeemed or cancelled'); -function dealRunning(deal: Deal, now: Date): boolean { - return deal.cancelledAt === null && deal.startsAt <= now && deal.endsAt > now; +function dealRunning(deal: Deal, venue: Venue, now: Date): boolean { + return deal.cancelledAt === null && deal.startsAt <= now && dealOverAt(deal, venue) > now; } async function lockBookableItem( @@ -100,10 +101,16 @@ async function lockBookableItem( return { item, venue }; } -async function lockBookableDeal(db: Queryable, item: MenuItem, dealId: number, now: Date): Promise { +async function lockBookableDeal( + db: Queryable, + item: MenuItem, + venue: Venue, + dealId: number, + now: Date, +): Promise { const deal = await deals.lockInVenue(db, item.venueId, dealId); if (deal?.menuItemId !== item.id) throw notFound('deal_not_found', 'Deal not found'); - if (!dealRunning(deal, now)) { + if (!dealRunning(deal, venue, now)) { throw conflict('deal_not_active', 'The deal is cancelled, over or has not started yet'); } return deal; @@ -161,10 +168,11 @@ export function createBookingsService({ ): Promise { if (!(await users.lock(client, userId))) throw notFound('user_not_found', 'User not found'); const { item, venue } = await lockBookableItem(client, input.menuItemId, userId); + const deal = + input.dealId === undefined ? null : await lockBookableDeal(client, item, venue, input.dealId, now); if (!isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone)) { throw conflict('venue_closed', 'The venue is closed now'); } - const deal = input.dealId === undefined ? null : await lockBookableDeal(client, item, input.dealId, now); const activeItems = await bookings.listActiveItemIds(client, userId); if (activeItems.includes(item.id)) { throw conflict('booking_exists', 'You already have an active booking of this item'); diff --git a/backend/src/services/catalog.int.test.ts b/backend/src/services/catalog.int.test.ts index ba60f20..6ecb5ba 100644 --- a/backend/src/services/catalog.int.test.ts +++ b/backend/src/services/catalog.int.test.ts @@ -278,6 +278,66 @@ describe('deal card', () => { }); }); +describe('deals after the closing', () => { + const at = (iso: string) => new Date(iso); + + it('treats a deal as over once the venue closes, even before its end time', async () => { + const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA }); + const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' }); + const deal = await seedDeal(pool, eclair, { + startsAt: at('2026-09-25T18:48:00Z'), + endsAt: at('2026-09-25T19:50:00Z'), + }); + clock.set('2026-09-25T18:55:00Z'); + expect((await catalog.deal(GUEST, deal.id)).deal.status).toBe('active'); + + clock.set('2026-09-25T19:05:00Z'); + await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ status: 404, code: 'deal_not_found' }); + expect((await catalog.venue(GUEST, venue.id)).deals).toEqual([]); + expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]); + expect(await deals.listVisible(pool, [venue.id], clock.now())).toEqual([]); + }); + + it('hides a deal published while the venue was closed', async () => { + const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA }); + const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' }); + const deal = await seedDeal(pool, eclair, { + startsAt: at('2026-09-25T19:30:00Z'), + endsAt: at('2026-09-25T20:30:00Z'), + }); + clock.set('2026-09-25T19:45:00Z'); + await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ code: 'deal_not_found' }); + expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]); + }); + + it('keeps deals past midnight and round the clock until their end', async () => { + const bar = await seedVenue(pool, 1, { + name: 'Бар', + location: BAUMANA, + opensAt: '18:00', + closesAt: '02:00', + }); + const allDay = await seedVenue(pool, 2, { + name: 'Пенка', + location: BAUMANA, + opensAt: '00:00', + closesAt: '00:00', + }); + const late = await seedDeal(pool, await seedMenuItem(pool, bar.id), { + startsAt: at('2026-09-25T20:00:00Z'), + endsAt: at('2026-09-25T22:30:00Z'), + }); + const night = await seedDeal(pool, await seedMenuItem(pool, allDay.id), { + startsAt: at('2026-09-25T20:00:00Z'), + endsAt: at('2026-09-26T02:00:00Z'), + }); + clock.set('2026-09-25T22:15:00Z'); + expect((await catalog.deal(GUEST, late.id)).deal.status).toBe('active'); + clock.set('2026-09-26T01:00:00Z'); + expect((await catalog.deal(GUEST, night.id)).deal.status).toBe('active'); + }); +}); + describe('demo mode', () => { it('measures from the centre of Kazan when the point is more than 50 km away', async () => { await seedDemoVenue(pool, 900001, { name: 'Зерно', location: BAUMANA }); diff --git a/backend/src/services/deals.int.test.ts b/backend/src/services/deals.int.test.ts index 83f1be8..1356727 100644 --- a/backend/src/services/deals.int.test.ts +++ b/backend/src/services/deals.int.test.ts @@ -345,6 +345,28 @@ describe('deals and opening hours', () => { }); }); +describe('deals after the closing', () => { + it('counts a deal as ended once the venue closes, even before its end time', async () => { + clock.set('2026-09-25T18:48:00Z'); + const late = await seedDeal(pool, item, { + startsAt: clock.now(), + endsAt: new Date('2026-09-25T19:50:00Z'), + }); + expect((await service.list(OWNER, 'active')).map((view) => view.deal.id)).toEqual([late.id]); + + clock.set('2026-09-25T19:05:00Z'); + expect(await service.list(OWNER, 'active')).toEqual([]); + expect(await service.list(OWNER, 'finished')).toMatchObject([{ status: 'ended', deal: { id: late.id } }]); + await expect(service.update(OWNER, late.id, { quantityLeft: 1 })).rejects.toMatchObject({ + status: 409, + code: 'deal_finished', + }); + + clock.set('2026-09-26T06:00:00Z'); + expect((await service.create(OWNER, input({ endsAt: inHours(1) }))).status).toBe('active'); + }); +}); + describe('listing deals', () => { it('lists live deals by end time and finished deals of the last 7 days newest first', async () => { const other = await seedMenuItem(pool, venue.id, { name: 'Круассан', priceRub: 150 }); diff --git a/backend/src/services/deals.test.ts b/backend/src/services/deals.test.ts index ed1a5c6..5e8c1e4 100644 --- a/backend/src/services/deals.test.ts +++ b/backend/src/services/deals.test.ts @@ -16,27 +16,36 @@ const deal: Deal = { }; const at = (iso: string) => new Date(iso); +const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' }; describe('dealStatus', () => { it('is scheduled until the start and active from the start', () => { - expect(dealStatus(deal, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled'); - expect(dealStatus(deal, at('2026-09-25T10:00:00Z'))).toBe('active'); - expect(dealStatus(deal, at('2026-09-25T11:59:59.999Z'))).toBe('active'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T10:00:00Z'))).toBe('active'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T11:59:59.999Z'))).toBe('active'); }); it('ends exactly at the end time', () => { - expect(dealStatus(deal, at('2026-09-25T12:00:00Z'))).toBe('ended'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T12:00:00Z'))).toBe('ended'); + }); + + it('ends at the closing of the venue when the end time is later', () => { + const late = { ...deal, startsAt: at('2026-09-25T18:48:00Z'), endsAt: at('2026-09-25T19:50:00Z') }; + expect(dealStatus(late, ZERNO, at('2026-09-25T18:59:59.999Z'))).toBe('active'); + expect(dealStatus(late, ZERNO, at('2026-09-25T19:00:00Z'))).toBe('ended'); + const allDay = { ...ZERNO, opensAt: '00:00', closesAt: '00:00' }; + expect(dealStatus(late, allDay, at('2026-09-25T19:30:00Z'))).toBe('active'); }); it('is sold out when no portions are left, even after the end', () => { const soldOut = { ...deal, quantityLeft: 0 }; - expect(dealStatus(soldOut, at('2026-09-25T11:00:00Z'))).toBe('sold_out'); - expect(dealStatus(soldOut, at('2026-09-25T13:00:00Z'))).toBe('sold_out'); + expect(dealStatus(soldOut, ZERNO, at('2026-09-25T11:00:00Z'))).toBe('sold_out'); + expect(dealStatus(soldOut, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('sold_out'); }); it('reports cancellation before anything else', () => { const cancelled = { ...deal, quantityLeft: 0, cancelledAt: at('2026-09-25T10:30:00Z') }; - expect(dealStatus(cancelled, at('2026-09-25T09:00:00Z'))).toBe('cancelled'); - expect(dealStatus(cancelled, at('2026-09-25T13:00:00Z'))).toBe('cancelled'); + expect(dealStatus(cancelled, ZERNO, at('2026-09-25T09:00:00Z'))).toBe('cancelled'); + expect(dealStatus(cancelled, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('cancelled'); }); }); diff --git a/backend/src/services/deals.ts b/backend/src/services/deals.ts index 1a30c40..258d52d 100644 --- a/backend/src/services/deals.ts +++ b/backend/src/services/deals.ts @@ -1,7 +1,9 @@ import { withTransaction, type Pool, type Queryable } from '../db/pool.ts'; +import { dealOverAt } from '../domain/deals.ts'; import type { Deal, MenuItem, Venue } from '../domain/models.ts'; import * as deals from '../repositories/deals.ts'; import * as menuItems from '../repositories/menu-items.ts'; +import * as venues from '../repositories/venues.ts'; import type { Clock } from '../shared/clock.ts'; import { conflict, notFound, unprocessable } from '../shared/errors.ts'; import { isOpenAt, nextClosingAt } from '../shared/time.ts'; @@ -48,21 +50,32 @@ const HOUR_MS = 3_600_000; const MAX_DEAL_WINDOW_MS = 24 * HOUR_MS; const FINISHED_HISTORY_MS = 7 * 24 * HOUR_MS; -export function dealStatus(deal: Deal, now: Date): DealStatus { +export function dealStatus( + deal: Deal, + venue: Pick, + now: Date, +): DealStatus { if (deal.cancelledAt) return 'cancelled'; if (deal.quantityLeft === 0) return 'sold_out'; - if (deal.endsAt <= now) return 'ended'; + if (dealOverAt(deal, venue) <= now) return 'ended'; if (deal.startsAt > now) return 'scheduled'; return 'active'; } export async function dealViews(db: Queryable, list: readonly Deal[], now: Date): Promise { const itemIds = [...new Set(list.map((deal) => deal.menuItemId))]; - const items = new Map((await menuItems.findByIds(db, itemIds)).map((item) => [item.id, item])); + const venueIds = [...new Set(list.map((deal) => deal.venueId))]; + const [foundItems, foundVenues] = await Promise.all([ + menuItems.findByIds(db, itemIds), + venues.findByIds(db, venueIds), + ]); + const items = new Map(foundItems.map((item) => [item.id, item])); + const places = new Map(foundVenues.map((venue) => [venue.id, venue])); return list.map((deal) => { const item = items.get(deal.menuItemId); - if (!item) throw new Error(`Deal ${deal.id} refers to a missing menu item`); - return { deal, item, status: dealStatus(deal, now) }; + const venue = places.get(deal.venueId); + if (!item || !venue) throw new Error(`Deal ${deal.id} refers to a missing menu item or venue`); + return { deal, item, status: dealStatus(deal, venue, now) }; }); } @@ -139,7 +152,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer }, now, ); - return { deal, item, status: dealStatus(deal, now) }; + return { deal, item, status: dealStatus(deal, venue, now) }; }); }, @@ -152,7 +165,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer const deal = await deals.lockInVenue(client, venue.id, dealId); if (!deal) throw dealNotFound(); const now = clock.now(); - if (deal.cancelledAt || deal.endsAt <= now || item.archivedAt) { + if (deal.cancelledAt || dealOverAt(deal, venue) <= now || item.archivedAt) { throw conflict('deal_finished', 'The deal is cancelled or over, create a new one'); } const quantityLeft = patch.quantityLeft ?? deal.quantityLeft; @@ -175,7 +188,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer quantityLeft, endsAt: patch.endsAt ?? deal.endsAt, }); - return { deal: updated, item, status: dealStatus(updated, now) }; + return { deal: updated, item, status: dealStatus(updated, venue, now) }; }); }, diff --git a/backend/src/services/recommendations.ts b/backend/src/services/recommendations.ts index 25fe818..f2d62f1 100644 --- a/backend/src/services/recommendations.ts +++ b/backend/src/services/recommendations.ts @@ -106,7 +106,7 @@ function toRecommendedOffer( offerId: offer.id, item, venue, - deal: deal ? { deal, item, status: dealStatus(deal, now) } : null, + deal: deal ? { deal, item, status: dealStatus(deal, venue, now) } : null, score: recommendation.score, distanceM: recommendation.distanceM, priceRub: recommendation.priceRub, diff --git a/backend/test/venue-bot.ts b/backend/test/venue-bot.ts index 537b7f6..b9c4ed4 100644 --- a/backend/test/venue-bot.ts +++ b/backend/test/venue-bot.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import { normalizeBookingCode } from '../src/domain/bookings.ts'; +import { dealOverAt } from '../src/domain/deals.ts'; import { menuNameKey } from '../src/domain/menu-imports.ts'; import type { Booking, Deal, MenuImport, MenuItem, ParsedMenuItem, Venue } from '../src/domain/models.ts'; import type { VenueAnalytics } from '../src/services/analytics.ts'; @@ -97,11 +98,17 @@ export function venueFake(clock: Clock) { const dealView = (deal: Deal): DealView => { const item = state.items.find((candidate) => candidate.id === deal.menuItemId); if (!item) throw new Error(`Deal ${deal.id} has no menu item`); - return { deal: { ...deal }, item: { ...item }, status: dealStatus(deal, clock.now()) }; + return { + deal: { ...deal }, + item: { ...item }, + status: dealStatus(deal, state.venue ?? sampleVenue, clock.now()), + }; }; const isLive = (deal: Deal) => - deal.cancelledAt === null && deal.quantityLeft > 0 && deal.endsAt > clock.now(); + deal.cancelledAt === null && + deal.quantityLeft > 0 && + dealOverAt(deal, state.venue ?? sampleVenue) > clock.now(); const bookingView = (booking: Booking): BookingView => { const item = state.items.find((candidate) => candidate.id === booking.menuItemId); diff --git a/docs/scenario.md b/docs/scenario.md index a3bd6d9..477a138 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -128,7 +128,7 @@ VENUE="Authorization: Bearer local-demo-venue-token-not-secret" | E6 | Отправить несуществующий код `AAAAAA` | «Бронь с таким кодом не найдена в вашем заведении. Проверьте код и введите его ещё раз.»; бот ждёт следующий ввод. В API `404` `booking_not_found`, в мини-приложении «Бронь с таким кодом не найдена в вашем заведении», при сканировании чужого QR «Это не QR брони ППшкин» | | E7 | Дождаться окончания брони (60 минут, конец акции или закрытие заведения) | Задача `expire_bookings` в течение минуты переводит бронь в `expired`, порция возвращается в горящую позицию. Гостю приходит «Бронь <код> истекла в ЧЧ:ММ: <позиция>, <заведение>. Код больше не действует.» с кнопкой «Подобрать другое»; в мини-приложении «Время брони вышло» и «Найти другое» | | E8 | Погасить истёкшую бронь | Бот: «Срок брони истёк. Гость может оформить новую»; API `409` `booking_expired`; уже погашенная или отменённая бронь: «Бронь уже погашена или отменена», `409` `booking_not_active` | -| E9 | Забронировать в закрытом заведении | Бот: «Заведение сейчас закрыто, бронь не получится. Посмотрим другое: /eat»; API `409` `venue_closed`; мини-приложение «Заведение сейчас закрыто, бронь недоступна» | +| E9 | Забронировать в закрытом заведении | Бот: «Заведение сейчас закрыто, бронь не получится. Посмотрим другое: /eat»; API `409` `venue_closed`; мини-приложение «Заведение сейчас закрыто, бронь недоступна». Горящее предложение после закрытия заведения считается закончившимся, даже если его время ещё не вышло: ссылка на него отвечает «Это предложение уже закончилось.», в подборе и в кабинете его нет, старая кнопка «Забронировать» отвечает «Предложение уже закончилось. Посмотрим другое: /eat», API `409` `deal_not_active` | | E10 | ChadGPT не ответил или ключа нет | «Сервис распознавания не ответил. Попробуйте через минуту или напишите вручную, например: Сырники 350» или «Распознавание фото сейчас выключено. Напишите, что съели, например: Сырники 350» с кнопкой «Ввести вручную». Повтор без перезапуска: отправить фото ещё раз через минуту или записать вручную | | E11 | Больше 20 распознаваний за час | «Слишком много запросов подряд. Попробуйте через час или запишите вручную, например: Сырники 350»; ручная запись работает | | E12 | Нажать кнопку старого сообщения или ту же кнопку повторно | Всплывает «Кнопка устарела» или «Эта кнопка уже нажата», действие не повторяется. Повторная доставка того же обновления MAX отбрасывается по ключу в `processed_updates`, запись не дублируется | diff --git a/openapi.yaml b/openapi.yaml index 8871d58..7690e7e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -962,7 +962,8 @@ components: - ended - cancelled description: active - продаётся; scheduled - ещё не началась; sold_out - порции - закончились; ended - время вышло; cancelled - снята заведением + закончились; ended - время вышло или заведение закрылось после + начала предложения; cancelled - снята заведением required: - id - menuItemId @@ -5295,14 +5296,15 @@ paths: предложения нет или оно на другую позицию. offer_not_found (404): предложение показано другому гостю или на другую позицию, бронируйте без offerId. venue_closed (409): заведение сейчас закрыто, покажите часы - работы. deal_not_active (409): горящее предложение снято, закончилось - или ещё не началось, предложите бронь по цене меню без dealId. - booking_exists (409): у гостя уже есть активная бронь этой позиции, - откройте её из GET /api/v1/bookings. deal_sold_out (409): порции по - предложению закончились, предложите бронь по цене меню без dealId. - too_many_bookings (409): уже 3 активные брони, отмените одну из них или - дождитесь, пока заведение её погасит. menu_item_unavailable (422): - заведение скрыло позицию от гостей." + работы. deal_not_active (409): горящее предложение снято, закончилось (в + том числе потому что заведение закрылось) или ещё не началось, + предложите бронь по цене меню без dealId. booking_exists (409): у гостя + уже есть активная бронь этой позиции, откройте её из GET + /api/v1/bookings. deal_sold_out (409): порции по предложению + закончились, предложите бронь по цене меню без dealId. too_many_bookings + (409): уже 3 активные брони, отмените одну из них или дождитесь, пока + заведение её погасит. menu_item_unavailable (422): заведение скрыло + позицию от гостей." requestBody: required: true content: