From 1a310e5f5aaf1fcac303402636bb7cf57ecb146b Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 14 Sep 2026 13:59:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(pickup):=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D1=81=D0=B0=D0=BC=D0=BE=D0=B2=D1=8B=D0=B2=D0=BE=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D1=85=20=D0=B4?= =?UTF-8?q?=D0=BD=D1=8F=D1=85=20(=D0=BF=D1=8F=D1=82=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=E2=86=92=20=D0=BF=D1=82,=20=D0=BF=D0=BD,=20=D0=B2?= =?UTF-8?q?=D1=82)=20=D1=83=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5;=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/autocall_scheduler.py | 39 ++++++------ src/components/client/PickupSlotsPicker.jsx | 61 +++++++++---------- .../client/PickupSlotsPicker.test.js | 49 +++++++++++++++ 3 files changed, 99 insertions(+), 50 deletions(-) create mode 100644 src/components/client/PickupSlotsPicker.test.js diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index fbc2231..ab1e600 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -294,15 +294,14 @@ def load_work_hours(conn): def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): """Даты, на которые предлагаем самовывоз. - Правило: забрать можно в день готовности (день попадания в базу — он же - день отправки ссылки) и два следующих дня, то есть максимум три дня - включая сегодняшний. Выходные и нерабочие дни пропускаем, отсчёт ведём - от сегодня: - • готов сегодня → сегодня, завтра, послезавтра (3 рабочих дня) - • готов вчера → сегодня, завтра (2) - • готов позавчера → сегодня (1) - • готов раньше → окно закрыто, звонить не о чем (0) - • пятница, готов сегодня → только пятница: сб и вс склад закрыт + Правило владельца: окно считается **рабочими днями** — день готовности + плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают + окно, а пропускаются): + • готов в понедельник → пн, вт, ср + • готов в пятницу → пт, пн, вт (сб и вс склад закрыт) + • готов в субботу → пн, вт (сам выходной не предлагаем) + • готов вчера → остаток окна начиная с сегодня + • окно прошло → пусто, звонить не о чем cutoff_minutes — время, после которого «сегодня» уже не предлагаем (склад не успеет собрать заказ к выдаче, настройка админки). @@ -313,20 +312,22 @@ def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): local_now = now.astimezone(tz) today = local_now.date() ready = (sms_sent_at or now).astimezone(tz).date() - last_allowed = ready + timedelta(days=2) - if last_allowed < today: - return [] + + # День готовности + два следующих рабочих дня. + window = [] + day = ready + guard = 0 + while len(window) < 3 and guard < 30: + if day.isoweekday() in work_days: + window.append(day) + day += timedelta(days=1) + guard += 1 + first_day = today # Поздно для сегодняшнего забора — окно начинается со следующего дня. if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes: first_day = today + timedelta(days=1) - dates = [] - day = first_day - while day <= last_allowed and len(dates) < 3: - if day.isoweekday() in work_days: - dates.append(day) - day += timedelta(days=1) - return [d.isoformat() for d in dates] + return [d.isoformat() for d in window if d >= first_day] def is_first_half_available(now=None, half_end_minutes=None): diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx index 3b9f781..ab54d36 100644 --- a/src/components/client/PickupSlotsPicker.jsx +++ b/src/components/client/PickupSlotsPicker.jsx @@ -81,22 +81,31 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => { * максимум три дня включая сегодняшний. Отсчёт в списке — от фактического * сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра». */ -const getPickupSlots = ( +export const getPickupSlots = ( referenceDate = new Date(), - pickupDays = [1,2,3,4,5], + pickupDays = [1, 2, 3, 4, 5], now = new Date(), schedule = {}, ) => { const todayKey = getCrimeaTodayKey(now); const readyKey = getCrimeaTodayKey(referenceDate); - const lastAllowedKey = addDaysKey(readyKey, 2); // Часы склада из админки («График работы»): граница половины дня и отсечка // «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»). const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60); const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60); const nowMinutes = getCrimeaMinutes(now); - // Дата попадает в окно: не раньше сегодня и не позже «готовность + 2 дня». - const isInWindow = (dateKey) => Boolean(dateKey) && dateKey >= todayKey && dateKey <= lastAllowedKey; + // Окно считается рабочими днями: день готовности + два следующих рабочих дня + // (пятница → пт, пн, вт). Прошедшие дни не показываем. + const windowKeys = []; + let windowDay = readyKey; + let guard = 0; + while (windowKeys.length < 3 && guard < 30) { + if (isAllowedPickupDay(windowDay, pickupDays)) windowKeys.push(windowDay); + windowDay = addDaysKey(windowDay, 1); + guard += 1; + } + const allowedKeys = windowKeys.filter((key) => key >= todayKey); + const PICKUP_TYPES = ["today", "tomorrow", "dayAfter"]; const slots = []; @@ -110,32 +119,22 @@ const getPickupSlots = ( }); }; - // Сегодня: первая половина — до границы половины дня, вторая — до отсечки - // «сегодня» (после неё склад не успеет собрать заказ, поэтому сегодня не предлагаем). - if ( - isAllowedPickupDay(todayKey, pickupDays) - && isInWindow(todayKey) - && nowMinutes < cutoffMinutes - ) { - if (nowMinutes < halfEndMinutes) addSlot(todayKey, "first", "Сегодня", "today"); - addSlot(todayKey, "second", "Сегодня", "today"); - } - - const tomorrow = addDaysKey(todayKey, 1); - const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays); - if (isInWindow(tomorrowWorkday)) { - const label = getDeliveryRelativeDayLabel(tomorrowWorkday, now) || "Завтра"; - addSlot(tomorrowWorkday, "first", label, "tomorrow"); - addSlot(tomorrowWorkday, "second", label, "tomorrow"); - } - - const dayAfter = addDaysKey(tomorrowWorkday, 1); - const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays); - if (isInWindow(dayAfterWorkday)) { - const label = getDeliveryRelativeDayLabel(dayAfterWorkday, now) || "Послезавтра"; - addSlot(dayAfterWorkday, "first", label, "dayAfter"); - addSlot(dayAfterWorkday, "second", label, "dayAfter"); - } + allowedKeys.forEach((dateKey, index) => { + const isToday = dateKey === todayKey; + const label = isToday + ? "Сегодня" + : (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`); + if (isToday) { + // После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем, + // первая половина доступна только до границы половины дня. + if (nowMinutes >= cutoffMinutes) return; + if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today"); + addSlot(dateKey, "second", label, "today"); + return; + } + addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter"); + addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter"); + }); return slots; }; diff --git a/src/components/client/PickupSlotsPicker.test.js b/src/components/client/PickupSlotsPicker.test.js new file mode 100644 index 0000000..5b0fa6b --- /dev/null +++ b/src/components/client/PickupSlotsPicker.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { getPickupSlots } from "./PickupSlotsPicker"; + +// Склад: пн–пт, половина дня до 13:00, после 15:30 «сегодня» не предлагаем. +const SCHEDULE = { halfEnd: "13:00", sameDayCutoff: "15:30" }; +const DAYS = [1, 2, 3, 4, 5]; + +const crimeaDate = (iso, hour, minute = 0) => new Date(`${iso}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+03:00`); + +// Пятница 18.09.2026, понедельник 21.09.2026, вторник 22.09.2026. +const FRIDAY = "2026-09-18"; +const MONDAY = "2026-09-21"; +const TUESDAY = "2026-09-22"; + +const datesOf = (slots) => [...new Set(slots.map((s) => s.date))]; + +describe("PickupSlotsPicker: окно самовывоза рабочими днями", () => { + it("готов в пятницу утром → пятница, понедельник, вторник", () => { + const slots = getPickupSlots(crimeaDate(FRIDAY, 9), DAYS, crimeaDate(FRIDAY, 9), SCHEDULE); + expect(datesOf(slots)).toEqual([FRIDAY, MONDAY, TUESDAY]); + }); + + it("готов в понедельник → понедельник, вторник, среда", () => { + const slots = getPickupSlots(crimeaDate(MONDAY, 10), DAYS, crimeaDate(MONDAY, 10), SCHEDULE); + expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]); + }); + + it("после отсечки 15:30 сегодня не предлагаем — остаются следующие дни окна", () => { + const slots = getPickupSlots(crimeaDate(MONDAY, 16), DAYS, crimeaDate(MONDAY, 16), SCHEDULE); + expect(datesOf(slots)).toEqual([TUESDAY, "2026-09-23"]); + }); + + it("после 13:00 первая половина сегодняшнего дня недоступна", () => { + const slots = getPickupSlots(crimeaDate(MONDAY, 14), DAYS, crimeaDate(MONDAY, 14), SCHEDULE); + const today = slots.filter((s) => s.date === MONDAY); + expect(today.map((s) => s.time)).toEqual(["Вторая половина дня"]); + }); + + it("готов в субботу (склад закрыт) → три рабочих дня с понедельника", () => { + const saturday = "2026-09-19"; + const slots = getPickupSlots(crimeaDate(saturday, 11), DAYS, crimeaDate(saturday, 11), SCHEDULE); + expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]); + }); + + it("окно прошло (готов неделю назад) → предложить нечего", () => { + const slots = getPickupSlots(crimeaDate("2026-09-10", 10), DAYS, crimeaDate(FRIDAY, 10), SCHEDULE); + expect(slots).toEqual([]); + }); +});