From 075e4f665d9cc4c9c700693dab3d061c6c155a59 Mon Sep 17 00:00:00 2001 From: savikthk Date: Sun, 27 Sep 2026 23:16:37 +0300 Subject: [PATCH] fix(deals): keep hot deals within the venue opening hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deal wizard offered "1 час" and "2 часа" without looking at the closing time and the service only checked the 24 hour window, so a deal published at 21:48 in a venue closing at 22:00 ran until 22:50. The service now rejects a deal that ends after the nearest closing or starts while the venue is closed with 422 deal_ends_after_closing, for POST and PATCH /api/v1/venue/deals alike. A scheduled deal is checked against the closing after its start. Round the clock venues have no closing limit and hours past midnight keep working. The bot and the mini app show only the durations that end before the closing, offer just "До закрытия" in the last hour, and less than 15 minutes before the closing or while the venue is closed explain when a deal can be published instead of starting the wizard. --- backend/src/bot/venue/deals.test.ts | 119 +++++++++++++++--- backend/src/bot/venue/deals.ts | 74 ++++++++--- backend/src/bot/venue/texts.ts | 8 +- backend/src/http/routes/venue.test.ts | 14 ++- backend/src/http/routes/venue.ts | 10 +- backend/src/http/schemas/deals.ts | 4 +- backend/src/services/deals.int.test.ts | 80 +++++++++++- backend/src/services/deals.ts | 23 +++- docs/scenario.md | 2 +- frontend/src/api/messages.ts | 1 + frontend/src/features/venue/DealScreens.tsx | 94 +++++++++----- frontend/src/features/venue/dealModel.test.ts | 66 ++++++++++ frontend/src/features/venue/dealModel.ts | 45 ++++++- frontend/src/features/venue/deals.test.tsx | 62 ++++++++- openapi.yaml | 52 +++++--- 15 files changed, 549 insertions(+), 105 deletions(-) diff --git a/backend/src/bot/venue/deals.test.ts b/backend/src/bot/venue/deals.test.ts index 92df291..728d187 100644 --- a/backend/src/bot/venue/deals.test.ts +++ b/backend/src/bot/venue/deals.test.ts @@ -396,12 +396,9 @@ describe('hot deal wizard', () => { expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'confirm', draft: { endsAt } }); }); - it.each([ - ['is open round the clock', '2026-09-26T09:00:00Z', { opensAt: '00:00', closesAt: '00:00' }], - ['closes in ten minutes', '2026-09-26T18:50:00Z', {}], - ])('does not offer closing time when the venue %s', async (_case, now, hours) => { - const chat = venueChat({ now }); - chat.fake.seedVenue(hours); + it('does not offer closing time when the venue is open round the clock', async () => { + const chat = venueChat({ now: '2026-09-26T21:30:00Z' }); + chat.fake.seedVenue({ opensAt: '00:00', closesAt: '00:00' }); putWizard(chat, 'until', chosen(chat.fake.seedItem(), { quantity: 5, priceRub: 120 })); const [reply] = sent(await chat.send('до закрытия')); @@ -410,24 +407,99 @@ describe('hot deal wizard', () => { expect(answers(await chat.press('vn:dl:until:close', reply?.messageId))[0]?.notification).toBe( 'Кнопка устарела', ); + await chat.press('vn:dl:until:120', reply?.messageId); + expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ + step: 'confirm', + draft: { endsAt: '2026-09-26T23:30:00.000Z' }, + }); }); - it('warns that guests cannot book while the venue is closed', async () => { - const chat = venueChat({ now: '2026-09-26T04:00:00Z' }); + it.each([ + ['an hour and a half', '2026-09-26T17:30:00Z', ['vn:dl:until:60', 'vn:dl:until:close']], + ['40 minutes', '2026-09-26T18:20:00Z', ['vn:dl:until:close']], + ])('offers only the durations that end before the closing in %s', async (_case, now, offered) => { + const chat = venueChat({ now }); chat.fake.seedVenue(); putWizard(chat, 'discount', chosen(chat.fake.seedItem(), { quantity: 3 })); const [reply] = answers(await chat.press('vn:dl:disc:50', WIZARD)); - expect(reply?.message?.text).toBe( - [ - 'Заведение сейчас закрыто по часам работы: гости не смогут забронировать до открытия.', - '', - 'Эклер: 100 ₽ вместо 200 ₽ (-50%), 3 шт.', - 'До скольки продаём?', - ].join('\n'), - ); + expect(reply?.message?.text).toBe('Эклер: 100 ₽ вместо 200 ₽ (-50%), 3 шт.\nДо скольки продаём?'); + expect(payloads(reply?.message)).toEqual([...offered, 'vn:dl:cancel']); expect(labels(reply?.message)).toContain('До закрытия, 22:00'); + expect(answers(await chat.press('vn:dl:until:120', WIZARD))[0]?.notification).toBe('Кнопка устарела'); + expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'until' }); + }); + + it('does not offer any end time ten minutes before the closing', async () => { + const chat = venueChat({ now: '2026-09-26T18:50:00Z' }); + chat.fake.seedVenue(); + putWizard(chat, 'until', chosen(chat.fake.seedItem(), { quantity: 5, priceRub: 120 })); + + const [reply] = sent(await chat.send('до закрытия')); + + expect(reply).toMatchObject({ + text: 'До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.', + buttons: [[DEALS]], + }); + expect(chat.states.peek(OWNER_ID).flow).toBeNull(); + for (const payload of ['vn:dl:until:60', 'vn:dl:until:120', 'vn:dl:until:close']) { + expect(answers(await chat.press(payload, WIZARD))[0]?.message?.text).toBe( + 'Мастер устарел, начните заново', + ); + } + expect(chat.states.peek(OWNER_ID).flow).toBeNull(); + expect(chat.fake.services.deals.create).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'ten minutes before the closing', + '2026-09-26T18:50:00Z', + {}, + 'До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.', + ], + [ + 'after the closing', + '2026-09-26T19:30:00Z', + {}, + 'Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.', + ], + [ + 'before the opening', + '2026-09-26T04:00:00Z', + {}, + 'Заведение сейчас закрыто, горящее можно выставить сегодня с 08:00.', + ], + [ + 'ten minutes before a closing after midnight', + '2026-09-26T22:50:00Z', + { opensAt: '18:00', closesAt: '02:00' }, + 'До закрытия меньше 15 минут, горящее можно выставить сегодня с 18:00.', + ], + ])('explains that a deal cannot start %s', async (_case, now, hours, text) => { + const chat = venueChat({ now }); + chat.fake.seedVenue(hours); + chat.fake.seedItem(); + + const [reply] = answers(await chat.press('vn:dl:new', 'mid.deals')); + + expect(reply?.message).toMatchObject({ text, buttons: [[DEALS]] }); + expect(chat.states.peek(OWNER_ID).flow).toBeNull(); + }); + + it('stops the wizard when the venue closes meanwhile', async () => { + const chat = venueChat({ now: '2026-09-26T19:30:00Z' }); + chat.fake.seedVenue(); + putWizard(chat, 'discount', chosen(chat.fake.seedItem(), { quantity: 3 })); + + const [reply] = answers(await chat.press('vn:dl:disc:50', WIZARD)); + + expect(reply?.message).toMatchObject({ + text: 'Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.', + buttons: [[DEALS]], + }); + expect(chat.states.peek(OWNER_ID).flow).toBeNull(); }); it('explains an existing deal on the item', async () => { @@ -481,6 +553,21 @@ describe('hot deal wizard', () => { }); }); + it('asks for the end time again when the deal would end after the closing', async () => { + const chat = ownerChat(); + atConfirm(chat, chat.fake.seedItem()); + vi.mocked(chat.fake.services.deals.create).mockRejectedValueOnce( + unprocessable('deal_ends_after_closing', 'After closing'), + ); + + const [reply] = sent(await chat.press('vn:dl:ok', WIZARD)); + + expect(reply?.text).toBe( + 'Время окончания позже закрытия заведения, выберите срок заново\n\nЭклер: 120 ₽ вместо 200 ₽ (-40%), 5 шт.\nДо скольки продаём?', + ); + expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'until' }); + }); + it.each([ [ 'a hidden item', diff --git a/backend/src/bot/venue/deals.ts b/backend/src/bot/venue/deals.ts index a9d0e1b..a197241 100644 --- a/backend/src/bot/venue/deals.ts +++ b/backend/src/bot/venue/deals.ts @@ -1,7 +1,7 @@ import type { MenuItem, Venue } from '../../domain/models.ts'; import type { Button, OutgoingMessage } from '../../ports/messenger.ts'; import type { DealView } from '../../services/deals.ts'; -import { formatLocalTime, isOpenAt, nextClosingAt } from '../../shared/time.ts'; +import { formatLocalTime, isOpenAt, localParts, minutesOfDay, nextClosingAt } from '../../shared/time.ts'; import { answerStale, byAction, parseId } from '../callbacks.ts'; import type { BotContext, BotKit, MessageInput } from '../context.ts'; import type { ActiveFlow, Flow } from '../state.ts'; @@ -28,6 +28,7 @@ import { dealOfferText, dealPublished, dealQuestion, + dealsPausedText, dealsText, dealSummary, discountOption, @@ -47,7 +48,6 @@ import { UNTIL_QUESTION, untilClosingOption, VENUE_BUTTONS, - VENUE_CLOSED_WARNING, VENUE_ERROR_TEXTS, WIZARD_STALE, withNote, @@ -70,6 +70,11 @@ type ChosenOffer = ChosenItem & DealOffer; interface Rendered { state: DealState | null; screen: OutgoingMessage; + paused?: boolean; +} + +interface SaleWindow { + closing: Date | null; } interface Presentation { @@ -93,6 +98,7 @@ const PUBLISH_FAILURES = [ 'deal_exists', 'deal_price_not_lower', 'deal_window_invalid', + 'deal_ends_after_closing', 'menu_item_unavailable', 'menu_item_not_found', ] as const satisfies readonly VenueErrorCode[]; @@ -122,9 +128,26 @@ function discountOptions(regularRub: number): { percent: number; priceRub: numbe })).filter(({ priceRub }) => priceRub >= 1 && priceRub < regularRub); } -function closingTime(venue: Venue, now: Date): Date | null { +function saleWindow(venue: Venue, now: Date): SaleWindow | null { + if (!isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone)) return null; const closing = nextClosingAt(venue.opensAt, venue.closesAt, now, venue.timezone); - return closing && closing.getTime() - now.getTime() >= CLOSING_MARGIN_MS ? closing : null; + if (closing && closing.getTime() - now.getTime() < CLOSING_MARGIN_MS) return null; + return { closing }; +} + +function endAfter(now: Date, minutes: number): Date { + return new Date(now.getTime() + minutes * MINUTE_MS); +} + +function fittingDurations({ closing }: SaleWindow, now: Date) { + return DURATION_CHOICES.filter(({ minutes }) => !closing || endAfter(now, minutes) <= closing); +} + +function pausedScreen(venue: Venue, now: Date): OutgoingMessage { + const { hour, minute } = localParts(now, venue.timezone); + const opensToday = hour * 60 + minute < (minutesOfDay(venue.opensAt) ?? 0); + const open = isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone); + return { text: dealsPausedText(open, opensToday, venue.opensAt), buttons: [dealsRow()] }; } function cancelRow(): Button[] { @@ -178,19 +201,24 @@ function discountScreen(item: ChosenItem, quantity: number): OutgoingMessage { } function endsAtChoice(value: string | undefined, venue: Venue, now: Date): Date | null { - if (value === UNTIL_CLOSING) return closingTime(venue, now); - const duration = DURATION_CHOICES.find(({ id }) => id === value); - return duration ? new Date(now.getTime() + duration.minutes * MINUTE_MS) : null; + const window = saleWindow(venue, now); + if (!window) return null; + if (value === UNTIL_CLOSING) return window.closing; + const duration = fittingDurations(window, now).find(({ id }) => id === value); + return duration ? endAfter(now, duration.minutes) : null; } -function untilScreen(offer: ChosenOffer, venue: Venue, now: Date): OutgoingMessage { - const closing = closingTime(venue, now); - const closed = !isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone); - const closingLabel = closing ? untilClosingOption(formatLocalTime(closing, venue.timezone)) : null; +function untilScreen(offer: ChosenOffer, window: SaleWindow, venue: Venue, now: Date): OutgoingMessage { + const durations = fittingDurations(window, now); + const closingLabel = window.closing + ? untilClosingOption(formatLocalTime(window.closing, venue.timezone)) + : null; return { - text: [...(closed ? [VENUE_CLOSED_WARNING, ''] : []), dealOfferText(offer), UNTIL_QUESTION].join('\n'), + text: [dealOfferText(offer), UNTIL_QUESTION].join('\n'), buttons: [ - DURATION_CHOICES.map(({ id, label }) => venueButton(label, 'dl', 'until', id)), + ...(durations.length > 0 + ? [durations.map(({ id, label }) => venueButton(label, 'dl', 'until', id))] + : []), ...(closingLabel ? [[venueButton(closingLabel, 'dl', 'until', UNTIL_CLOSING)]] : []), cancelRow(), ], @@ -208,7 +236,12 @@ function inputScreen(text: string): OutgoingMessage { return { text, buttons: [cancelRow()] }; } -function stepScreen({ step, draft }: DealState, venue: Venue, now: Date): OutgoingMessage | null { +function stepScreen( + { step, draft }: DealState, + window: SaleWindow, + venue: Venue, + now: Date, +): OutgoingMessage | null { const item = chosenItem(draft); const offer = chosenOffer(draft); switch (step) { @@ -223,7 +256,7 @@ function stepScreen({ step, draft }: DealState, venue: Venue, now: Date): Outgoi case 'price_input': return item ? inputScreen(pricePrompt(item.itemPriceRub - 1)) : null; case 'until': - return offer ? untilScreen(offer, venue, now) : null; + return offer ? untilScreen(offer, window, venue, now) : null; case 'confirm': return offer && draft.endsAt !== undefined ? confirmScreen(offer, new Date(draft.endsAt), venue) : null; } @@ -280,14 +313,17 @@ export function createDealScreens({ services }: BotKit) { } async function render(ctx: BotContext, venue: Venue, state: DealState): Promise { - const screen = stepScreen(state, venue, ctx.now); + const window = saleWindow(venue, ctx.now); + if (!window) return { state: null, screen: pausedScreen(venue, ctx.now), paused: true }; + const screen = stepScreen(state, window, venue, ctx.now); if (screen) return { state, screen }; return renderItems(ctx, state.step === 'item' ? state.page : 1); } async function present(ctx: BotContext, venue: Venue, next: DealState, how: Presentation): Promise { - const { state, screen } = await render(ctx, venue, next); - const message = how.error === undefined ? screen : { ...screen, text: withNote(how.error, screen.text) }; + const { state, screen, paused } = await render(ctx, venue, next); + const message = + how.error === undefined || paused ? screen : { ...screen, text: withNote(how.error, screen.text) }; if (how.via === 'answer') { if (state) await saveFlow(ctx, { name: 'deal_wizard', ...state, messageId: ctx.callbackMessageId }); else await clearFlow(ctx, 'deal_wizard'); @@ -403,7 +439,7 @@ export function createDealScreens({ services }: BotKit) { code: (typeof PUBLISH_FAILURES)[number], ): Promise { const error = VENUE_ERROR_TEXTS[code]; - if (code === 'deal_window_invalid') { + if (code === 'deal_window_invalid' || code === 'deal_ends_after_closing') { const draft = { ...state.draft, endsAt: undefined }; await present(ctx, venue, { ...state, step: 'until', draft }, { via: 'reply', error }); return; diff --git a/backend/src/bot/venue/texts.ts b/backend/src/bot/venue/texts.ts index 7d20839..69a22ff 100644 --- a/backend/src/bot/venue/texts.ts +++ b/backend/src/bot/venue/texts.ts @@ -89,6 +89,7 @@ export const VENUE_ERROR_TEXTS = { deal_exists: 'На эту позицию уже есть горящее предложение', deal_price_not_lower: 'Цена со скидкой должна быть ниже обычной', deal_window_invalid: 'Время окончания должно быть в ближайшие 24 часа', + deal_ends_after_closing: 'Время окончания позже закрытия заведения, выберите срок заново', menu_item_unavailable: 'Позиция скрыта от гостей, включите её в кабинете', menu_item_not_found: 'Позиция уже удалена, обновите список', deal_not_found: 'Позиция уже удалена, обновите список', @@ -155,8 +156,6 @@ export const QUANTITY_PROMPT = `Напишите, сколько порций п export const QUANTITY_INVALID = `Нужно целое число от 1 до ${DEAL_QUANTITY_LIMIT}, например 7.`; export const DISCOUNT_QUESTION = 'Какая скидка?'; export const UNTIL_QUESTION = 'До скольки продаём?'; -export const VENUE_CLOSED_WARNING = - 'Заведение сейчас закрыто по часам работы: гости не смогут забронировать до открытия.'; export const DEAL_CANCELLED = 'Хорошо, ничего не публикую.'; const NO_BOOKINGS = 'Активных броней нет.'; @@ -354,6 +353,11 @@ export function priceInvalid(maxRub: number): string { return `Нужно целое число от 1 до ${maxRub}: цена со скидкой ниже обычной.`; } +export function dealsPausedText(open: boolean, opensToday: boolean, opensAt: string): string { + const reason = open ? 'До закрытия меньше 15 минут' : 'Заведение сейчас закрыто'; + return `${reason}, горящее можно выставить ${opensToday ? 'сегодня' : 'завтра'} с ${opensAt}.`; +} + export function untilClosingOption(closesAt: string): string { return `${VENUE_BUTTONS.untilClosing}, ${closesAt}`; } diff --git a/backend/src/http/routes/venue.test.ts b/backend/src/http/routes/venue.test.ts index c125b8c..0de10bc 100644 --- a/backend/src/http/routes/venue.test.ts +++ b/backend/src/http/routes/venue.test.ts @@ -695,6 +695,7 @@ describe('deal routes', () => { unprocessable('menu_item_unavailable', 'x'), unprocessable('deal_price_not_lower', 'x'), unprocessable('deal_window_invalid', 'x'), + unprocessable('deal_ends_after_closing', 'x'), ]; const create = vi.fn(); for (const failure of failures) create.mockRejectedValueOnce(failure); @@ -717,7 +718,8 @@ describe('deal routes', () => { .fn() .mockResolvedValueOnce({ ...dealView, deal: { ...sampleDeal, quantityLeft: 0 }, status: 'sold_out' }) .mockRejectedValueOnce(conflict('deal_finished', 'over')) - .mockRejectedValueOnce(unprocessable('deal_quantity_invalid', 'too many')); + .mockRejectedValueOnce(unprocessable('deal_quantity_invalid', 'too many')) + .mockRejectedValueOnce(unprocessable('deal_ends_after_closing', 'after closing')); app = await buildTestApp({ services: { deals: dealsStub({ update }) } }); const url = '/api/v1/venue/deals/21'; const path = '/api/v1/venue/deals/{id}'; @@ -745,6 +747,16 @@ describe('deal routes', () => { expect(tooMany.statusCode).toBe(422); expectContract(tooMany, 'PATCH', path); + const afterClosing = await app.inject({ + method: 'PATCH', + url, + headers: owner, + payload: { endsAt: '2026-09-25T19:30:00Z' }, + }); + expect(afterClosing.statusCode).toBe(422); + expectContract(afterClosing, 'PATCH', path); + expect(afterClosing.json()).toMatchObject({ code: 'deal_ends_after_closing' }); + for (const payload of [{}, { quantityLeft: -1 }, { endsAt: 'soon' }]) { const invalid = await app.inject({ method: 'PATCH', url, headers: owner, payload }); expect(invalid.statusCode, JSON.stringify(payload)).toBe(400); diff --git a/backend/src/http/routes/venue.ts b/backend/src/http/routes/venue.ts index ec05e2f..2f17248 100644 --- a/backend/src/http/routes/venue.ts +++ b/backend/src/http/routes/venue.ts @@ -37,6 +37,8 @@ interface VenueRouteOptions { const NO_VENUE = 'venue_not_found (404, заведения ещё нет, предложите создать его через POST /api/v1/venue)'; const BAD_INPUT = 'validation_failed (400, исправьте поля из errors)'; +const ENDS_AFTER_CLOSING = + 'deal_ends_after_closing (422, окончание позже ближайшего закрытия заведения или заведение сейчас закрыто: выберите окончание не позже закрытия или выставьте предложение в часы работы)'; const BAD_ID = 'validation_failed (400, id должен быть положительным целым числом)'; const IMPORT_POLLING = @@ -356,12 +358,14 @@ export const venueRoutes: FastifyPluginCallbackZod = ( tags: ['venue'], summary: 'Выставить горящее предложение', description: [ - 'Позиция продаётся со скидкой с текущего момента до endsAt, не дольше 24 часов.', + 'Позиция продаётся со скидкой с текущего момента до endsAt, не дольше 24 часов и не позже ближайшего закрытия заведения.', + 'Выставить предложение можно только в часы работы заведения, у круглосуточного заведения ограничения по закрытию нет.', 'Если владелец выбирает скидку в процентах, цену считает клиент: round(цена позиции * (1 - процент / 100)).', 'Коды ошибок: menu_item_not_found (404, позиция удалена или принадлежит другому заведению),', 'menu_item_unavailable (422, позиция скрыта от гостей, сначала включите её),', 'deal_price_not_lower (422, цена предложения должна быть ниже цены в меню),', 'deal_window_invalid (422, выберите окончание в ближайшие 24 часа),', + `${ENDS_AFTER_CLOSING},`, 'deal_exists (409, на позицию уже есть горящее предложение, измените или снимите его),', `${NO_VENUE}, ${BAD_INPUT}.`, ].join(' '), @@ -390,13 +394,15 @@ export const venueRoutes: FastifyPluginCallbackZod = ( summary: 'Изменить горящее предложение', description: [ 'Меняет остаток порций или время окончания, нужно хотя бы одно поле.', + 'Новое время окончания не позже ближайшего закрытия заведения, считая от начала предложения или от текущего момента, если предложение уже идёт.', 'Коды ошибок: deal_not_found (404, предложение принадлежит другому заведению или не существует),', 'deal_finished (409, предложение снято, его время вышло или позиция удалена, выставьте новое),', 'deal_exists (409, вернуть порции распроданному предложению нельзя: на позицию уже выставлено новое),', 'menu_item_unavailable (422, чтобы вернуть порции распроданному предложению, сначала включите позицию),', 'deal_price_not_lower (422, цена в меню стала не выше цены предложения, выставьте новое предложение),', 'deal_quantity_invalid (422, остаток не может быть больше quantityTotal),', - `deal_window_invalid (422, выберите окончание в ближайшие 24 часа), ${NO_VENUE}, ${BAD_INPUT}.`, + 'deal_window_invalid (422, выберите окончание в ближайшие 24 часа),', + `${ENDS_AFTER_CLOSING}, ${NO_VENUE}, ${BAD_INPUT}.`, ].join(' '), security: bearerSecurity, params: IdParams, diff --git a/backend/src/http/schemas/deals.ts b/backend/src/http/schemas/deals.ts index 78b692c..69c802c 100644 --- a/backend/src/http/schemas/deals.ts +++ b/backend/src/http/schemas/deals.ts @@ -29,7 +29,9 @@ export const DealListSchema = z.object({ items: z.array(DealSchema) }); const DealEnd = z.iso .datetime({ offset: true }) - .describe('Окончание акции, ISO 8601: позже текущего момента и не позже чем через 24 часа'); + .describe( + 'Окончание акции, ISO 8601: позже текущего момента, не позже чем через 24 часа и не позже ближайшего закрытия заведения (у круглосуточного заведения ограничения по закрытию нет)', + ); export const DealBody = z.object({ menuItemId: z.number().int().positive(), diff --git a/backend/src/services/deals.int.test.ts b/backend/src/services/deals.int.test.ts index 07e9784..83f1be8 100644 --- a/backend/src/services/deals.int.test.ts +++ b/backend/src/services/deals.int.test.ts @@ -97,7 +97,10 @@ describe('creating deals', () => { code: 'deal_window_invalid', }); } - expect((await service.create(OWNER, input({ endsAt: inHours(24) }))).status).toBe('active'); + const allDay = await seedVenue(pool, OTHER_OWNER, { opensAt: '00:00', closesAt: '00:00' }); + const coffee = await seedMenuItem(pool, allDay.id, { priceRub: 200 }); + const longest = input({ menuItemId: coffee.id, endsAt: inHours(24) }); + expect((await service.create(OTHER_OWNER, longest)).status).toBe('active'); }); it('allows one live deal per item and a new one after it sells out, ends or is cancelled', async () => { @@ -267,6 +270,81 @@ describe('changing deals', () => { }); }); +describe('deals and opening hours', () => { + const at = (iso: string) => new Date(iso); + + it('ends a deal no later than the venue closes', async () => { + clock.set('2026-09-25T18:48:00Z'); + for (const endsAt of [inHours(1), inHours(2), at('2026-09-25T19:00:01Z')]) { + await expect(service.create(OWNER, input({ endsAt }))).rejects.toMatchObject({ + status: 422, + code: 'deal_ends_after_closing', + }); + } + const view = await service.create(OWNER, input({ endsAt: at('2026-09-25T19:00:00Z') })); + expect(view.deal.endsAt).toEqual(at('2026-09-25T19:00:00Z')); + }); + + it.each([ + ['after closing', '2026-09-25T19:30:00Z'], + ['before opening', '2026-09-25T04:00:00Z'], + ])('does not start a deal %s', async (_case, now) => { + clock.set(now); + await expect(service.create(OWNER, input({ endsAt: inHours(1) }))).rejects.toMatchObject({ + status: 422, + code: 'deal_ends_after_closing', + }); + expect(await service.list(OWNER, 'active')).toEqual([]); + }); + + it('keeps a new end time of a deal before the closing', async () => { + clock.set('2026-09-25T17:00:00Z'); + const { deal } = await service.create(OWNER, input({ endsAt: inHours(1) })); + await expect(service.update(OWNER, deal.id, { endsAt: inHours(3) })).rejects.toMatchObject({ + status: 422, + code: 'deal_ends_after_closing', + }); + expect((await service.update(OWNER, deal.id, { endsAt: inHours(2) })).deal.endsAt).toEqual(inHours(2)); + clock.set('2026-09-25T18:55:00Z'); + expect((await service.update(OWNER, deal.id, { quantityLeft: 2 })).deal.quantityLeft).toBe(2); + await expect( + service.update(OWNER, deal.id, { endsAt: at('2026-09-25T19:30:00Z') }), + ).rejects.toMatchObject({ code: 'deal_ends_after_closing' }); + }); + + it('checks a scheduled deal against the closing after its start', async () => { + clock.set('2026-09-25T04:00:00Z'); + const scheduled = await seedDeal(pool, item, { + startsAt: at('2026-09-25T05:00:00Z'), + endsAt: at('2026-09-25T09:00:00Z'), + }); + const moved = await service.update(OWNER, scheduled.id, { endsAt: at('2026-09-25T19:00:00Z') }); + expect(moved.status).toBe('scheduled'); + await expect( + service.update(OWNER, scheduled.id, { endsAt: at('2026-09-25T19:01:00Z') }), + ).rejects.toMatchObject({ code: 'deal_ends_after_closing' }); + }); + + it('has no closing limit round the clock', async () => { + const allDay = await seedVenue(pool, OTHER_OWNER, { opensAt: '00:00', closesAt: '00:00' }); + const coffee = await seedMenuItem(pool, allDay.id, { priceRub: 200 }); + clock.set('2026-09-25T21:30:00Z'); + const view = await service.create(OTHER_OWNER, input({ menuItemId: coffee.id, endsAt: inHours(20) })); + expect(view.status).toBe('active'); + }); + + it('sells until a closing after midnight', async () => { + const bar = await seedVenue(pool, OTHER_OWNER, { opensAt: '18:00', closesAt: '02:00' }); + const snack = await seedMenuItem(pool, bar.id, { priceRub: 200 }); + clock.set('2026-09-25T20:30:00Z'); + await expect( + service.create(OTHER_OWNER, input({ menuItemId: snack.id, endsAt: inHours(3) })), + ).rejects.toMatchObject({ code: 'deal_ends_after_closing' }); + const view = await service.create(OTHER_OWNER, input({ menuItemId: snack.id, endsAt: inHours(2.5) })); + expect(view.deal.endsAt).toEqual(at('2026-09-25T23:00:00Z')); + }); +}); + 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.ts b/backend/src/services/deals.ts index b020eb9..1a30c40 100644 --- a/backend/src/services/deals.ts +++ b/backend/src/services/deals.ts @@ -1,9 +1,10 @@ import { withTransaction, type Pool, type Queryable } from '../db/pool.ts'; -import type { Deal, MenuItem } from '../domain/models.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 type { Clock } from '../shared/clock.ts'; import { conflict, notFound, unprocessable } from '../shared/errors.ts'; +import { isOpenAt, nextClosingAt } from '../shared/time.ts'; import { lockMenuItem } from './menu.ts'; import { requireOwnedVenue } from './venues.ts'; @@ -76,6 +77,20 @@ function assertWindow(endsAt: Date, now: Date, startsAt: Date = now): void { } } +function assertBeforeClosing(venue: Venue, endsAt: Date, from: Date): void { + const closing = nextClosingAt(venue.opensAt, venue.closesAt, from, venue.timezone); + if (!closing) return; + if (!isOpenAt(venue.opensAt, venue.closesAt, from, venue.timezone)) { + throw unprocessable( + 'deal_ends_after_closing', + 'The venue is closed, publish the deal during its opening hours', + ); + } + if (endsAt > closing) { + throw unprocessable('deal_ends_after_closing', 'The deal must end no later than the venue closes'); + } +} + function assertSellable(item: MenuItem, priceRub: number): void { if (!item.isAvailable) { throw unprocessable('menu_item_unavailable', 'The item is hidden from guests, make it available first'); @@ -111,6 +126,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer assertSellable(item, input.priceRub); const now = clock.now(); assertWindow(input.endsAt, now); + assertBeforeClosing(venue, input.endsAt, now); if (await deals.findLiveForItem(client, item.id, now)) throw dealExists(); const deal = await deals.insert( client, @@ -146,7 +162,10 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer `Quantity left cannot exceed the ${deal.quantityTotal} portions of the deal`, ); } - if (patch.endsAt) assertWindow(patch.endsAt, now, deal.startsAt); + if (patch.endsAt) { + assertWindow(patch.endsAt, now, deal.startsAt); + assertBeforeClosing(venue, patch.endsAt, deal.startsAt > now ? deal.startsAt : now); + } if (quantityLeft > 0) { if (deal.quantityLeft === 0) assertSellable(item, deal.priceRub); const live = await deals.findLiveForItem(client, item.id, now); diff --git a/docs/scenario.md b/docs/scenario.md index 52c5a99..a3bd6d9 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -71,7 +71,7 @@ | C4 | «Горящее», затем «Новое горящее предложение» | Список горящих позиций копии или «Нет горящих позиций...»; после нажатия «Что выставляем?» с кнопками позиций «<позиция>, <цена> ₽» (позиции, на которые уже есть горящее предложение, не предлагаются) | | C5 | Выбрать «Эклер, 190 ₽» | «Эклер, 190 ₽» и «Сколько порций?» с кнопками 1, 3, 5, 10 и «Другое» | | C6 | «3» | «Какая скидка?» с кнопками «-20%, 152 ₽», «-30%, 133 ₽», «-40%, 114 ₽», «-50%, 95 ₽» и «Своя цена» | -| C7 | «-30%, 133 ₽» | «Эклер: 133 ₽ вместо 190 ₽ (-30%), 3 шт.» и «До скольки продаём?» с кнопками «1 час», «2 часа» и «До закрытия, 22:00» (если до закрытия больше 15 минут) | +| C7 | «-30%, 133 ₽» | «Эклер: 133 ₽ вместо 190 ₽ (-30%), 3 шт.» и «До скольки продаём?» с кнопками «1 час» и «2 часа», если срок укладывается до закрытия, и «До закрытия, 22:00». Срок не может выходить за закрытие заведения; если до закрытия меньше 15 минут или заведение закрыто, мастер отвечает «До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.» или «Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.», API отвечает `422` `deal_ends_after_closing` | | C8 | «1 час», затем «Опубликовать» | «Эклер: 133 ₽ вместо 190 ₽ (-30%), 3 шт., до ЧЧ:ММ. Опубликовать?»; после публикации «Опубликовано. Гости рядом увидят предложение в подборе. Ссылка для гостей: https://max.ru/t516_hakaton_max_bot?start=d_» | | C9 | Открыть ссылку для гостей из C8 | Карточка горящей позиции: «Эклер», «133 ₽ вместо 190 ₽, около 270 ккал», «До ЧЧ:ММ, осталось 3 шт.», заведение и адрес, «Заведение и меню тестовые», кнопка «Забронировать». Позиция может появиться и в подборе «Что поесть?» этого аккаунта, если подходит по калориям и расстоянию | | C10 | «Забронировать» | Как гость: «Бронь <код>», «Эклер, 133 ₽», «Действует до ЧЧ:ММ. Покажите код или QR на кассе.» и QR. Как заведение, в тот же чат: «Новая бронь <код>», «Эклер, 133 ₽», «Действует до ЧЧ:ММ» с кнопками «Погасить» и «Все брони» | diff --git a/frontend/src/api/messages.ts b/frontend/src/api/messages.ts index 6f8e7cd..1277b0c 100644 --- a/frontend/src/api/messages.ts +++ b/frontend/src/api/messages.ts @@ -48,6 +48,7 @@ const MESSAGES: Record = { deal_finished: 'Предложение уже завершено или снято, изменить его нельзя', deal_price_not_lower: 'Цена со скидкой должна быть ниже обычной цены', deal_window_invalid: 'Время окончания должно быть в ближайшие 24 часа', + deal_ends_after_closing: 'Время окончания позже закрытия заведения, выберите срок до закрытия', deal_quantity_invalid: 'Остаток не может быть больше исходного количества', deal_not_active: 'Предложение уже закончилось', deal_sold_out: 'Все порции уже разобрали', diff --git a/frontend/src/features/venue/DealScreens.tsx b/frontend/src/features/venue/DealScreens.tsx index cecf222..704b07a 100644 --- a/frontend/src/features/venue/DealScreens.tsx +++ b/frontend/src/features/venue/DealScreens.tsx @@ -6,7 +6,6 @@ import { userMessage } from '../../api/messages.ts'; import { buildStartAppLink } from '../../app/startParam.ts'; import { haptic } from '../../max/bridge.ts'; import { formatPrice } from '../../shared/format.ts'; -import { isOpenNow } from '../../shared/openNow.ts'; import { useOnline } from '../../shared/useOnline.ts'; import { useUnsavedChanges } from '../../shared/useUnsavedChanges.tsx'; import { ActionBar } from '../../shared/ui/ActionBar.tsx'; @@ -26,16 +25,22 @@ import { DEAL_STATUS_LABELS, dealPreview, dealPriceLine, + defaultEnd, discountedPrice, DISCOUNTS, + endsBeforeClosing, + fittingHours, MAX_QUANTITY, QUANTITIES, resolveEnd, + saleWindow, + salePausedText, validDealPrice, venueClock, withinWindow, type Deal, type EndChoice, + type SaleWindow, } from './dealModel.ts'; import { useCancelDeal, useCreateDeal, useUpdateDeal, useVenueDeals } from './dealQueries.ts'; import { normalizeName, type MenuItem, type Venue } from './model.ts'; @@ -54,6 +59,14 @@ function dealError(error: unknown): string { return isApiError(error) ? (FORM_ERRORS[error.code] ?? userMessage(error)) : userMessage(error); } +function endError(endsAt: Date, window: SaleWindow, venue: Venue, now: Date): string | null { + if (!withinWindow(endsAt, now)) return 'Время окончания должно быть в ближайшие 24 часа'; + if (window.closing !== null && !endsBeforeClosing(endsAt, window)) { + return `Время окончания должно быть не позже закрытия в ${venueClock(window.closing.toISOString(), venue.timezone)}`; + } + return null; +} + function DealCard({ deal, venue, finished }: { deal: Deal; venue: Venue; finished: boolean }) { const navigate = useNavigate(); const toast = useToast(); @@ -234,34 +247,32 @@ function quantityOf(quantity: Quantity): number | null { function EndChoices({ venue, + window, + now, value, onChange, }: { venue: Venue; + window: SaleWindow; + now: Date; value: EndChoice | { kind: 'none' }; onChange: (value: EndChoice) => void; }) { - const roundTheClock = venue.opensAt === venue.closesAt; return ( <> - { - onChange({ kind: 'hours', hours: 1 }); - }} - > - 1 час - - { - onChange({ kind: 'hours', hours: 2 }); - }} - > - 2 часа - - {!roundTheClock && ( + {fittingHours(window, now).map((hours) => ( + { + onChange({ kind: 'hours', hours }); + }} + > + {hours === 1 ? '1 час' : '2 часа'} + + ))} + {window.closing !== null && ( { @@ -320,7 +331,10 @@ function DealForm({ venue, menu, active }: { venue: Venue; menu: MenuItem[]; act const [search, setSearch] = useState(''); const [quantity, setQuantity] = useState(() => initialQuantity(params)); const [price, setPrice] = useState(() => initialPrice(params)); - const [end, setEnd] = useState({ kind: 'hours', hours: 2 }); + const [end, setEnd] = useState(() => { + const opened = new Date(); + return defaultEnd(saleWindow(venue, opened), opened); + }); const [error, setError] = useState<{ text: string; open: boolean } | null>(null); const [fieldErrors, setFieldErrors] = useState>({}); const [published, setPublished] = useState(null); @@ -332,11 +346,13 @@ function DealForm({ venue, menu, active }: { venue: Venue; menu: MenuItem[]; act const busy = new Set(active.map((deal) => deal.menuItemId)); const item = available.find((entry) => entry.id === itemId); const now = new Date(); + const window = saleWindow(venue, now); const amount = priceOf(price, item); const count = quantityOf(quantity); const endsAt = resolveEnd(end, venue, now); const priceValid = item !== undefined && amount !== null && validDealPrice(amount, item.priceRub); - const endValid = endsAt !== null && withinWindow(endsAt, now); + const endProblem = endsAt === null || window === null ? null : endError(endsAt, window, venue, now); + const endValid = endsAt !== null && window !== null && endProblem === null; const ready = item !== undefined && !busy.has(item.id) && priceValid && count !== null && endValid; const shown = search.trim().length === 0 @@ -403,9 +419,7 @@ function DealForm({ venue, menu, active }: { venue: Venue; menu: MenuItem[]; act return ( <> - {!isOpenNow(venue.opensAt, venue.closesAt, venue.timezone, now) && ( - Заведение сейчас закрыто: гости не смогут забронировать до открытия - )} + {window === null && {salePausedText(venue, now)}}

Позиция

{available.length > 8 && ( )} {fieldErrors.priceRub !== undefined &&

{fieldErrors.priceRub}

} -

До какого времени

- - {fieldErrors.endsAt !== undefined &&

{fieldErrors.endsAt}

} - {endsAt !== null && !endValid && ( -

Время окончания должно быть в ближайшие 24 часа

+ {window !== null && ( + <> +

До какого времени

+ + )} + {fieldErrors.endsAt !== undefined &&

{fieldErrors.endsAt}

} + {endProblem !== null &&

{endProblem}

} {ready && (

{dealPreview(item.name, amount, item.priceRub, count, endsAt, venue.timezone)} @@ -606,8 +622,10 @@ function EditDeal({ deal, venue }: { deal: Deal; venue: Venue }) { const quantity = Number(shown.trim()); const numeric = shown.trim().length > 0 && /^\d+$/.test(shown.trim()); const quantityValid = numeric && quantity <= deal.quantityTotal; + const window = saleWindow(venue, new Date(Math.max(now.getTime(), Date.parse(deal.startsAt)))); const endsAt = end === null ? null : resolveEnd(end, venue, now); - const endValid = end === null || (endsAt !== null && withinWindow(endsAt, now)); + const endProblem = endsAt === null || window === null ? null : endError(endsAt, window, venue, now); + const endValid = end === null || (endsAt !== null && window !== null && endProblem === null); const patch = { ...(left !== null && quantityValid && quantity !== deal.quantityLeft ? { quantityLeft: quantity } : {}), ...(endsAt !== null && endValid ? { endsAt: endsAt.toISOString() } : {}), @@ -667,8 +685,20 @@ function EditDeal({ deal, venue }: { deal: Deal; venue: Venue }) { } />

Новое время окончания

- - {!endValid &&

Время окончания должно быть в ближайшие 24 часа

} + {window === null ? ( +

+ Время окончания можно изменить в часы работы заведения, но не позже чем за 15 минут до закрытия +

+ ) : ( + + )} + {endProblem !== null &&

{endProblem}

} {fieldErrors.endsAt !== undefined &&

{fieldErrors.endsAt}

} {error !== null && {error}} diff --git a/frontend/src/features/venue/dealModel.test.ts b/frontend/src/features/venue/dealModel.test.ts index af73914..7b853a7 100644 --- a/frontend/src/features/venue/dealModel.test.ts +++ b/frontend/src/features/venue/dealModel.test.ts @@ -6,7 +6,11 @@ import { discountedPrice, formatRate, normalizeBookingCode, + defaultEnd, + endsBeforeClosing, resolveEnd, + saleWindow, + salePausedText, validDealPrice, venueTimeToInstant, withinWindow, @@ -70,6 +74,68 @@ describe('end time', () => { }); }); +describe('sale window', () => { + const at = (iso: string) => new Date(iso); + + it('sells until the closing while the venue is open', () => { + expect(saleWindow(VENUE, at('2026-09-26T12:00:00.000Z'))).toEqual({ + closing: at('2026-09-26T19:00:00.000Z'), + }); + expect( + saleWindow({ ...VENUE, opensAt: '18:00', closesAt: '02:00' }, at('2026-09-26T20:00:00.000Z')), + ).toEqual({ + closing: at('2026-09-26T23:00:00.000Z'), + }); + expect( + saleWindow({ ...VENUE, opensAt: '00:00', closesAt: '00:00' }, at('2026-09-26T21:30:00.000Z')), + ).toEqual({ + closing: null, + }); + }); + + it('does not sell while closed or less than 15 minutes before the closing', () => { + expect(saleWindow(VENUE, at('2026-09-26T18:45:00.000Z'))).not.toBeNull(); + expect(saleWindow(VENUE, at('2026-09-26T18:50:00.000Z'))).toBeNull(); + expect(saleWindow(VENUE, at('2026-09-26T19:30:00.000Z'))).toBeNull(); + expect(saleWindow(VENUE, at('2026-09-26T04:00:00.000Z'))).toBeNull(); + }); + + it('keeps the end no later than the closing', () => { + const window = { closing: at('2026-09-26T19:00:00.000Z') }; + expect(endsBeforeClosing(at('2026-09-26T19:00:00.000Z'), window)).toBe(true); + expect(endsBeforeClosing(at('2026-09-26T19:00:01.000Z'), window)).toBe(false); + expect(endsBeforeClosing(at('2026-09-27T12:00:00.000Z'), { closing: null })).toBe(true); + }); + + it('picks the longest quick option that fits before the closing', () => { + const closing = at('2026-09-26T19:00:00.000Z'); + expect(defaultEnd({ closing }, at('2026-09-26T12:00:00.000Z'))).toEqual({ kind: 'hours', hours: 2 }); + expect(defaultEnd({ closing }, at('2026-09-26T17:30:00.000Z'))).toEqual({ kind: 'hours', hours: 1 }); + expect(defaultEnd({ closing }, at('2026-09-26T18:20:00.000Z'))).toEqual({ kind: 'closing' }); + expect(defaultEnd({ closing: null }, at('2026-09-26T18:20:00.000Z'))).toEqual({ + kind: 'hours', + hours: 2, + }); + }); + + it.each([ + [ + '2026-09-26T18:50:00.000Z', + VENUE, + 'До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.', + ], + ['2026-09-26T19:30:00.000Z', VENUE, 'Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.'], + ['2026-09-26T04:00:00.000Z', VENUE, 'Заведение сейчас закрыто, горящее можно выставить сегодня с 08:00.'], + [ + '2026-09-26T22:50:00.000Z', + { ...VENUE, opensAt: '18:00', closesAt: '02:00' }, + 'До закрытия меньше 15 минут, горящее можно выставить сегодня с 18:00.', + ], + ])('explains when a deal can start at %s', (now, venue, text) => { + expect(salePausedText(venue, at(now))).toBe(text); + }); +}); + describe('booking code', () => { it.each([ ['K7M2QX', 'K7M2QX'], diff --git a/frontend/src/features/venue/dealModel.ts b/frontend/src/features/venue/dealModel.ts index 9cdac72..65f2568 100644 --- a/frontend/src/features/venue/dealModel.ts +++ b/frontend/src/features/venue/dealModel.ts @@ -1,5 +1,6 @@ import type { Schemas } from '../../api/client.ts'; import { formatPrice } from '../../shared/format.ts'; +import { isOpenNow } from '../../shared/openNow.ts'; import { fromZonedInput, toZonedInput } from '../../shared/zonedTime.ts'; import type { Venue } from './model.ts'; @@ -10,6 +11,13 @@ export const QUANTITIES = [1, 3, 5, 10] as const; export const MAX_QUANTITY = 100; const DAY_MS = 24 * 60 * 60_000; const HOUR_MS = 60 * 60_000; +const CLOSING_MARGIN_MS = 15 * 60_000; + +type OpeningHours = Pick; + +export interface SaleWindow { + closing: Date | null; +} export const DEAL_STATUS_LABELS: Record = { active: 'Продаётся', @@ -48,7 +56,7 @@ function nextLocalTime(time: string, timeZone: string, now: Date): Date | null { return venueTimeToInstant(nextDay(today), time, timeZone); } -function closingTime(venue: Pick, now: Date): Date | null { +function closingTime(venue: OpeningHours, now: Date): Date | null { if (venue.opensAt === venue.closesAt) return null; return nextLocalTime(venue.closesAt, venue.timezone, now); } @@ -56,11 +64,7 @@ function closingTime(venue: Pick, no export type EndChoice = { kind: 'hours'; hours: 1 | 2 } | { kind: 'closing' } | { kind: 'time'; time: string }; -export function resolveEnd( - choice: EndChoice, - venue: Pick, - now: Date, -): Date | null { +export function resolveEnd(choice: EndChoice, venue: OpeningHours, now: Date): Date | null { switch (choice.kind) { case 'hours': return new Date(now.getTime() + choice.hours * HOUR_MS); @@ -76,6 +80,35 @@ export function withinWindow(endsAt: Date, now: Date): boolean { return delta > 0 && delta <= DAY_MS; } +export function saleWindow(venue: OpeningHours, now: Date): SaleWindow | null { + if (!isOpenNow(venue.opensAt, venue.closesAt, venue.timezone, now)) return null; + const closing = closingTime(venue, now); + if (closing !== null && closing.getTime() - now.getTime() < CLOSING_MARGIN_MS) return null; + return { closing }; +} + +export function endsBeforeClosing(endsAt: Date, window: SaleWindow): boolean { + return window.closing === null || endsAt.getTime() <= window.closing.getTime(); +} + +export function fittingHours(window: SaleWindow, now: Date): (1 | 2)[] { + return ([1, 2] as const).filter((hours) => + endsBeforeClosing(new Date(now.getTime() + hours * HOUR_MS), window), + ); +} + +export function defaultEnd(window: SaleWindow | null, now: Date): EndChoice { + const longest = window === null ? 2 : fittingHours(window, now).at(-1); + return longest === undefined ? { kind: 'closing' } : { kind: 'hours', hours: longest }; +} + +export function salePausedText(venue: OpeningHours, now: Date): string { + const open = isOpenNow(venue.opensAt, venue.closesAt, venue.timezone, now); + const opensToday = toZonedInput(now, venue.timezone).slice(11, 16) < venue.opensAt; + const reason = open ? 'До закрытия меньше 15 минут' : 'Заведение сейчас закрыто'; + return `${reason}, горящее можно выставить ${opensToday ? 'сегодня' : 'завтра'} с ${venue.opensAt}.`; +} + export function venueClock(iso: string, timeZone: string): string { return new Intl.DateTimeFormat('ru-RU', { timeZone, hour: '2-digit', minute: '2-digit' }).format( new Date(iso), diff --git a/frontend/src/features/venue/deals.test.tsx b/frontend/src/features/venue/deals.test.tsx index 68f9a32..9ad77e4 100644 --- a/frontend/src/features/venue/deals.test.tsx +++ b/frontend/src/features/venue/deals.test.tsx @@ -154,12 +154,60 @@ describe('deal form', () => { expect(screen.getByRole('button', { name: 'Поделиться' })).toBeTruthy(); }); - it('disables discounts that do not lower the price and warns about a closed venue', async () => { + it('disables discounts that do not lower the price and does not publish in a closed venue', async () => { await start({ ...VENUE, opensAt: '18:00', closesAt: '23:00' }); await renderApp('/venue/deals/new?itemId=2'); - expect(await screen.findByText(/Заведение сейчас закрыто/)).toBeTruthy(); + expect( + await screen.findByText('Заведение сейчас закрыто, горящее можно выставить сегодня с 18:00.'), + ).toBeTruthy(); expect(screen.getByRole('button', { name: /^-50%, 1/ }).hasAttribute('disabled')).toBe(false); expect(screen.getByRole('button', { name: /^-20%, 2/ }).hasAttribute('disabled')).toBe(true); + expect(screen.queryByRole('button', { name: '1 час' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Опубликовать' }).hasAttribute('disabled')).toBe(true); + }); + + it('offers only end times before the closing', async () => { + vi.setSystemTime(new Date('2026-09-26T18:20:00.000Z')); + await start(); + server.on('POST', '/api/v1/venue/deals', (call) => json(deal({ ...(call.body as object), id: 5 }), 201)); + await renderApp('/venue/deals/new?itemId=1'); + expect(await screen.findByRole('button', { name: 'До закрытия' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '1 час' })).toBeNull(); + expect(screen.queryByRole('button', { name: '2 часа' })).toBeNull(); + expect(screen.getByText(/^Чизкейк: 133.₽ вместо 190.₽ \(-30%\), 5 шт\., до 22:00$/)).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Другое время' })); + fireEvent.change(screen.getByLabelText('Время окончания'), { target: { value: '22:30' } }); + expect(screen.getByText('Время окончания должно быть не позже закрытия в 22:00')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Опубликовать' }).hasAttribute('disabled')).toBe(true); + + fireEvent.change(screen.getByLabelText('Время окончания'), { target: { value: '21:50' } }); + fireEvent.click(screen.getByRole('button', { name: 'Опубликовать' })); + expect(await screen.findByText('Горящая позиция опубликована')).toBeTruthy(); + expect(server.callsTo('POST', '/api/v1/venue/deals')[0]?.body).toMatchObject({ + endsAt: '2026-09-26T18:50:00.000Z', + }); + }); + + it('explains that a deal cannot start ten minutes before the closing', async () => { + vi.setSystemTime(new Date('2026-09-26T18:50:00.000Z')); + await start(); + await renderApp('/venue/deals/new?itemId=1'); + expect( + await screen.findByText('До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.'), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'До закрытия' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Опубликовать' }).hasAttribute('disabled')).toBe(true); + }); + + it('explains a deal that the server finds ending after the closing', async () => { + await start(); + server.on('POST', '/api/v1/venue/deals', () => problem(422, 'deal_ends_after_closing')); + await renderApp('/venue/deals/new?itemId=1'); + fireEvent.click(await screen.findByRole('button', { name: 'Опубликовать' })); + expect( + await screen.findByText('Время окончания позже закрытия заведения, выберите срок до закрытия'), + ).toBeTruthy(); }); it('hides "До закрытия" for a round the clock venue and explains an existing deal', async () => { @@ -376,6 +424,16 @@ describe('review fixes', () => { }); }); + it('changes the end time of a deal only within the opening hours', async () => { + vi.setSystemTime(new Date('2026-09-26T17:30:00.000Z')); + await start(); + server.on('GET', '/api/v1/venue/deals', () => json({ items: [deal()] })); + await renderApp('/venue/deals/3'); + expect(await screen.findByRole('button', { name: '1 час' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '2 часа' })).toBeNull(); + expect(screen.getByRole('button', { name: 'До закрытия' })).toBeTruthy(); + }); + it('leaves a prefilled form without asking when nothing changed', async () => { await start(); const { router } = await renderApp('/venue/deals/new?itemId=1'); diff --git a/openapi.yaml b/openapi.yaml index 4559613..8871d58 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4447,13 +4447,18 @@ paths: tags: - venue description: "Позиция продаётся со скидкой с текущего момента до endsAt, не - дольше 24 часов. Если владелец выбирает скидку в процентах, цену считает - клиент: round(цена позиции * (1 - процент / 100)). Коды ошибок: - menu_item_not_found (404, позиция удалена или принадлежит другому - заведению), menu_item_unavailable (422, позиция скрыта от гостей, - сначала включите её), deal_price_not_lower (422, цена предложения должна - быть ниже цены в меню), deal_window_invalid (422, выберите окончание в - ближайшие 24 часа), deal_exists (409, на позицию уже есть горящее + дольше 24 часов и не позже ближайшего закрытия заведения. Выставить + предложение можно только в часы работы заведения, у круглосуточного + заведения ограничения по закрытию нет. Если владелец выбирает скидку в + процентах, цену считает клиент: round(цена позиции * (1 - процент / + 100)). Коды ошибок: menu_item_not_found (404, позиция удалена или + принадлежит другому заведению), menu_item_unavailable (422, позиция + скрыта от гостей, сначала включите её), deal_price_not_lower (422, цена + предложения должна быть ниже цены в меню), deal_window_invalid (422, + выберите окончание в ближайшие 24 часа), deal_ends_after_closing (422, + окончание позже ближайшего закрытия заведения или заведение сейчас + закрыто: выберите окончание не позже закрытия или выставьте предложение + в часы работы), deal_exists (409, на позицию уже есть горящее предложение, измените или снимите его), venue_not_found (404, заведения ещё нет, предложите создать его через POST /api/v1/venue), validation_failed (400, исправьте поля из errors)." @@ -4481,8 +4486,9 @@ paths: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: "Окончание акции, ISO 8601: позже текущего момента и не позже чем - через 24 часа" + description: "Окончание акции, ISO 8601: позже текущего момента, не позже чем + через 24 часа и не позже ближайшего закрытия заведения (у + круглосуточного заведения ограничения по закрытию нет)" required: - menuItemId - priceRub @@ -4559,18 +4565,23 @@ paths: tags: - venue description: "Меняет остаток порций или время окончания, нужно хотя бы одно - поле. Коды ошибок: deal_not_found (404, предложение принадлежит другому - заведению или не существует), deal_finished (409, предложение снято, его - время вышло или позиция удалена, выставьте новое), deal_exists (409, - вернуть порции распроданному предложению нельзя: на позицию уже - выставлено новое), menu_item_unavailable (422, чтобы вернуть порции - распроданному предложению, сначала включите позицию), + поле. Новое время окончания не позже ближайшего закрытия заведения, + считая от начала предложения или от текущего момента, если предложение + уже идёт. Коды ошибок: deal_not_found (404, предложение принадлежит + другому заведению или не существует), deal_finished (409, предложение + снято, его время вышло или позиция удалена, выставьте новое), + deal_exists (409, вернуть порции распроданному предложению нельзя: на + позицию уже выставлено новое), menu_item_unavailable (422, чтобы вернуть + порции распроданному предложению, сначала включите позицию), deal_price_not_lower (422, цена в меню стала не выше цены предложения, выставьте новое предложение), deal_quantity_invalid (422, остаток не может быть больше quantityTotal), deal_window_invalid (422, выберите - окончание в ближайшие 24 часа), venue_not_found (404, заведения ещё нет, - предложите создать его через POST /api/v1/venue), validation_failed - (400, исправьте поля из errors)." + окончание в ближайшие 24 часа), deal_ends_after_closing (422, окончание + позже ближайшего закрытия заведения или заведение сейчас закрыто: + выберите окончание не позже закрытия или выставьте предложение в часы + работы), venue_not_found (404, заведения ещё нет, предложите создать его + через POST /api/v1/venue), validation_failed (400, исправьте поля из + errors)." requestBody: required: true content: @@ -4586,8 +4597,9 @@ paths: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: "Окончание акции, ISO 8601: позже текущего момента и не позже чем - через 24 часа" + description: "Окончание акции, ISO 8601: позже текущего момента, не позже чем + через 24 часа и не позже ближайшего закрытия заведения (у + круглосуточного заведения ограничения по закрытию нет)" minProperties: 1 parameters: - schema: