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: