diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 0d33a85..fbc2231 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -256,7 +256,42 @@ def load_work_days(conn, field="pickup_days"): return default -def pickup_window(sms_sent_at, work_days=None, now=None): +def load_work_hours(conn): + """Рабочие часы склада и доставки из «Графика работы» (админка). + + Возвращает минуты от полуночи — сравнивать часы строками нельзя. + Дефолты повторяют прежнее поведение кода: первая половина до 12:00, + «сегодня» предлагалось весь день. + """ + defaults = { + "pickup_half_end": 12 * 60, + "pickup_same_day_cutoff": 24 * 60, + "pickup_close": 18 * 60, + "delivery_half_end": 15 * 60, + "delivery_close": 21 * 60, + } + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close, + delivery_half_end, delivery_close + FROM business_schedule_settings WHERE id = 1 + """) + row = cur.fetchone() or {} + hours = {} + for key, fallback in defaults.items(): + value = row.get(key) + if value is None: + hours[key] = fallback + continue + hours[key] = value.hour * 60 + value.minute + return hours + except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту + log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc) + return dict(defaults) + + +def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): """Даты, на которые предлагаем самовывоз. Правило: забрать можно в день готовности (день попадания в базу — он же @@ -267,18 +302,26 @@ def pickup_window(sms_sent_at, work_days=None, now=None): • готов вчера → сегодня, завтра (2) • готов позавчера → сегодня (1) • готов раньше → окно закрыто, звонить не о чем (0) - • в пятницу готов → пятница и суббота (воскресенье закрыто) + • пятница, готов сегодня → только пятница: сб и вс склад закрыт + + cutoff_minutes — время, после которого «сегодня» уже не предлагаем + (склад не успеет собрать заказ к выдаче, настройка админки). """ now = now or datetime.now(timezone.utc) work_days = work_days or [1, 2, 3, 4, 5] tz = timezone(timedelta(hours=3)) - today = now.astimezone(tz).date() + 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 [] + 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 = today + day = first_day while day <= last_allowed and len(dates) < 3: if day.isoweekday() in work_days: dates.append(day) @@ -286,11 +329,13 @@ def pickup_window(sms_sent_at, work_days=None, now=None): return [d.isoformat() for d in dates] -def is_today_before_noon(now=None): - """До 12:00 по Симферополю — первую половину дня ещё можно предлагать.""" +def is_first_half_available(now=None, half_end_minutes=None): + """До границы половины дня (настройка склада, по умолчанию 12:00) + первую половину дня ещё можно предлагать клиенту.""" now = now or datetime.now(timezone.utc) local = now.astimezone(timezone(timedelta(hours=3))) - return local.hour < 12 + limit = 12 * 60 if half_end_minutes is None else half_end_minutes + return local.hour * 60 + local.minute < limit def resolve_call_type(group): @@ -334,6 +379,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): pickup_days = pickup_window( group.get("sms_sent_at") or group.get("created_at"), settings.get("pickup_work_days"), + cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"), ) if not pickup_days: # Окно самовывоза закрыто (заказ готов более двух дней назад). @@ -356,7 +402,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}", f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}", f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}", - f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_today_before_noon()) else 0}", + f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}", f"Setvar: API_URL={API_URL}", f"Setvar: API_KEY={API_KEY}", f"Setvar: TEST_MODE={1 if test_mode else 0}", @@ -575,8 +621,9 @@ def main(): conn = get_db_conn() try: settings = load_settings(conn) - # Рабочие дни для окна самовывоза (выходные робот не предлагает) + # Рабочие дни и часы для окна самовывоза (выходные/закрытие склада робот не предлагает) settings["pickup_work_days"] = load_work_days(conn) + settings["work_hours"] = load_work_hours(conn) # Добор «не взял трубку»: заявки, по которым сеть так и не ответила # (истёкший call-файл, AGI не запускался). Только работа с БД. diff --git a/src/components/admin/BusinessSchedulePanel.jsx b/src/components/admin/BusinessSchedulePanel.jsx index da36a7f..07d212b 100644 --- a/src/components/admin/BusinessSchedulePanel.jsx +++ b/src/components/admin/BusinessSchedulePanel.jsx @@ -23,8 +23,41 @@ const DEFAULT_SCHEDULE = { deliveryDays: [1, 2, 3, 4, 5], pickupDays: [1, 2, 3, 4, 5], smsDays: [1, 2, 3, 4, 5], + // Рабочие часы: склад (самовывоз) и доставка. + pickupOpen: "09:00", + pickupClose: "17:30", + pickupHalfEnd: "13:00", + pickupSameDayCutoff: "15:30", + deliveryOpen: "09:00", + deliveryClose: "21:00", + deliveryHalfEnd: "15:00", }; +const TIME_FIELDS = [ + "pickupOpen", + "pickupClose", + "pickupHalfEnd", + "pickupSameDayCutoff", + "deliveryOpen", + "deliveryClose", + "deliveryHalfEnd", +]; + +const TimeField = ({ label, hint, value, onChange }) => ( + +); + const DayPills = ({ selectedDays, onToggle, accentColor }) => { const toggle = (num) => { if (selectedDays.includes(num)) { @@ -79,6 +112,13 @@ export const BusinessSchedulePanel = () => { deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays, pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays, smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays, + pickupOpen: data.pickupOpen || DEFAULT_SCHEDULE.pickupOpen, + pickupClose: data.pickupClose || DEFAULT_SCHEDULE.pickupClose, + pickupHalfEnd: data.pickupHalfEnd || DEFAULT_SCHEDULE.pickupHalfEnd, + pickupSameDayCutoff: data.pickupSameDayCutoff || DEFAULT_SCHEDULE.pickupSameDayCutoff, + deliveryOpen: data.deliveryOpen || DEFAULT_SCHEDULE.deliveryOpen, + deliveryClose: data.deliveryClose || DEFAULT_SCHEDULE.deliveryClose, + deliveryHalfEnd: data.deliveryHalfEnd || DEFAULT_SCHEDULE.deliveryHalfEnd, }; setSchedule(loaded); setOriginal(loaded); @@ -97,7 +137,8 @@ export const BusinessSchedulePanel = () => { const hasChanges = JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) || JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) || - JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays); + JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays) || + TIME_FIELDS.some((field) => schedule[field] !== original[field]); const handleSave = async () => { if (!hasChanges) return; @@ -115,6 +156,13 @@ export const BusinessSchedulePanel = () => { p_delivery_days: schedule.deliveryDays, p_pickup_days: schedule.pickupDays, p_sms_days: schedule.smsDays, + p_pickup_open: schedule.pickupOpen, + p_pickup_close: schedule.pickupClose, + p_pickup_half_end: schedule.pickupHalfEnd, + p_pickup_same_day_cutoff: schedule.pickupSameDayCutoff, + p_delivery_open: schedule.deliveryOpen, + p_delivery_close: schedule.deliveryClose, + p_delivery_half_end: schedule.deliveryHalfEnd, }); if (rpcError) throw rpcError; if (data?.ok) { @@ -183,6 +231,26 @@ export const BusinessSchedulePanel = () => {

Выбрано: {formatDays(schedule.deliveryDays) || "—"}

+
+ setSchedule((s) => ({ ...s, deliveryOpen: v }))} + /> + setSchedule((s) => ({ ...s, deliveryClose: v }))} + /> + setSchedule((s) => ({ ...s, deliveryHalfEnd: v }))} + /> +
{/* Pickup days */} @@ -206,6 +274,32 @@ export const BusinessSchedulePanel = () => {

Выбрано: {formatDays(schedule.pickupDays) || "—"}

+
+ setSchedule((s) => ({ ...s, pickupOpen: v }))} + /> + setSchedule((s) => ({ ...s, pickupClose: v }))} + /> + setSchedule((s) => ({ ...s, pickupHalfEnd: v }))} + /> + setSchedule((s) => ({ ...s, pickupSameDayCutoff: v }))} + /> +
{/* SMS days */} @@ -217,7 +311,7 @@ export const BusinessSchedulePanel = () => { SMS-приглашения

- Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно). + Дни, в которые отправляются SMS-приглашения клиентам.

diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx index 48ce41c..3b9f781 100644 --- a/src/components/client/PickupSlotsPicker.jsx +++ b/src/components/client/PickupSlotsPicker.jsx @@ -36,6 +36,26 @@ const getCrimeaHour = (referenceDate = new Date()) => { ); }; +// Минуты от полуночи по Симферополю — чтобы сравнивать с настройками склада. +const getCrimeaMinutes = (referenceDate = new Date()) => { + const parts = new Intl.DateTimeFormat("ru-RU", { + timeZone: DELIVERY_TIMEZONE, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(referenceDate); + const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0", 10); + const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0", 10); + return hour * 60 + minute; +}; + +// "15:30" → 930 +const timeToMinutes = (value, fallback) => { + const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim()); + if (!match) return fallback; + return Number(match[1]) * 60 + Number(match[2]); +}; + const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => { if (!dateKey) return false; const d = new Date(`${dateKey}T12:00:00Z`); @@ -61,11 +81,20 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => { * максимум три дня включая сегодняшний. Отсчёт в списке — от фактического * сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра». */ -const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], now = new Date()) => { +const getPickupSlots = ( + referenceDate = new Date(), + pickupDays = [1,2,3,4,5], + now = new Date(), + schedule = {}, +) => { const todayKey = getCrimeaTodayKey(now); - const hour = getCrimeaHour(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; @@ -81,9 +110,14 @@ const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], no }); }; - // Сегодня: первая половина — только до 12:00, вторая — в течение дня. - if (isAllowedPickupDay(todayKey, pickupDays) && isInWindow(todayKey)) { - if (hour < 12) addSlot(todayKey, "first", "Сегодня", "today"); + // Сегодня: первая половина — до границы половины дня, вторая — до отсечки + // «сегодня» (после неё склад не успеет собрать заказ, поэтому сегодня не предлагаем). + if ( + isAllowedPickupDay(todayKey, pickupDays) + && isInWindow(todayKey) + && nowMinutes < cutoffMinutes + ) { + if (nowMinutes < halfEndMinutes) addSlot(todayKey, "first", "Сегодня", "today"); addSlot(todayKey, "second", "Сегодня", "today"); } @@ -146,8 +180,14 @@ export const PickupSlotsPicker = ({ selectedSlotId, referenceDate = new Date(), pickupDays = [1, 2, 3, 4, 5], + schedule = {}, }) => { - const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]); + const halfEnd = schedule?.halfEnd; + const sameDayCutoff = schedule?.sameDayCutoff; + const slots = React.useMemo( + () => getPickupSlots(referenceDate, pickupDays, new Date(), { halfEnd, sameDayCutoff }), + [referenceDate, pickupDays, halfEnd, sameDayCutoff], + ); if (!slots.length) { return ( diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 9f38332..545840c 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -14,9 +14,8 @@ import { Skeleton } from "../components/UI/Loading"; import { formatDeliveryDate } from "../components/client/deliveryDateFormatting"; import { confirmDeliveryChoice, + fetchBusinessSchedule, fetchDeliveryInvitation, - fetchDeliveryWorkDays, - fetchPickupWorkDays, } from "../services/deliveryInvitationApi"; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; @@ -329,19 +328,21 @@ export const ClientDeliveryPage = () => { [token, invitation?.smsSentAt], ); - // Рабочие дни — из графика компании, те же, что называет робот. + // Рабочие дни и часы — из графика компании, те же, что использует робот. const [pickupWorkDays, setPickupWorkDays] = React.useState([1, 2, 3, 4, 5]); const [deliveryWorkDays, setDeliveryWorkDays] = React.useState(DEFAULT_DELIVERY_DAYS); + const [pickupSchedule, setPickupSchedule] = React.useState({ + halfEnd: "13:00", + sameDayCutoff: "15:30", + }); React.useEffect(() => { let cancelled = false; - fetchDeliveryWorkDays() - .then((days) => { - if (!cancelled) setDeliveryWorkDays(days); - }) - .catch(() => {}); - fetchPickupWorkDays() - .then((days) => { - if (!cancelled) setPickupWorkDays(days); + fetchBusinessSchedule() + .then((s) => { + if (cancelled) return; + setPickupWorkDays(s.pickupDays); + setDeliveryWorkDays(s.deliveryDays); + setPickupSchedule({ halfEnd: s.pickupHalfEnd, sameDayCutoff: s.pickupSameDayCutoff }); }) .catch(() => {}); return () => { @@ -687,6 +688,7 @@ export const ClientDeliveryPage = () => { selectedSlotId={selectedSlotId} referenceDate={referenceDate} pickupDays={pickupWorkDays} + schedule={pickupSchedule} /> ) : null} diff --git a/src/pages/ClientDeliveryPage.test.js b/src/pages/ClientDeliveryPage.test.js index 0f5b2d2..8b7ab68 100644 --- a/src/pages/ClientDeliveryPage.test.js +++ b/src/pages/ClientDeliveryPage.test.js @@ -44,6 +44,7 @@ describe("ClientDeliveryPage helpers", () => { searchDate: "2026-04-14", }), ).toEqual({ + deliveryType: "delivery", deliveryDate: "2026-04-16", deliveryTime: "После обеда", }); diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index fa9245f..4dfa387 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -193,6 +193,13 @@ const SCHEDULE_FALLBACK = { deliveryDays: [1, 2, 3, 4, 5, 6], pickupDays: [1, 2, 3, 4, 5], smsDays: [1, 2, 3, 4, 5], + pickupOpen: "09:00", + pickupClose: "17:30", + pickupHalfEnd: "13:00", + pickupSameDayCutoff: "15:30", + deliveryOpen: "09:00", + deliveryClose: "21:00", + deliveryHalfEnd: "15:00", }; const normalizeDays = (days, fallback) => { @@ -213,6 +220,13 @@ export const fetchBusinessSchedule = async () => { deliveryDays: normalizeDays(data?.deliveryDays, SCHEDULE_FALLBACK.deliveryDays), pickupDays: normalizeDays(data?.pickupDays, SCHEDULE_FALLBACK.pickupDays), smsDays: normalizeDays(data?.smsDays, SCHEDULE_FALLBACK.smsDays), + pickupOpen: data?.pickupOpen || SCHEDULE_FALLBACK.pickupOpen, + pickupClose: data?.pickupClose || SCHEDULE_FALLBACK.pickupClose, + pickupHalfEnd: data?.pickupHalfEnd || SCHEDULE_FALLBACK.pickupHalfEnd, + pickupSameDayCutoff: data?.pickupSameDayCutoff || SCHEDULE_FALLBACK.pickupSameDayCutoff, + deliveryOpen: data?.deliveryOpen || SCHEDULE_FALLBACK.deliveryOpen, + deliveryClose: data?.deliveryClose || SCHEDULE_FALLBACK.deliveryClose, + deliveryHalfEnd: data?.deliveryHalfEnd || SCHEDULE_FALLBACK.deliveryHalfEnd, }; } catch (e) { console.warn("Не удалось получить график работы, беру значения по умолчанию", e);