diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 4eed0b7..083e797 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -604,15 +604,14 @@ def process_queue(conn, settings): mark_queue_processed(conn, q["queue_id"]) pushed += 1 else: - # Звонить не о чем: окно звонка закрыто (например, заказ готов - # раньше, чем «сегодня + 2 рабочих дня» самовывоза). Закрываем - # заявку навсегда — иначе она молча крутится в очереди, и в - # карточке вечно висит «в очереди, позвоним в течение минуты». - log.warning( - f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто, " - "заявка закрыта — роботу нечего предлагать" + # Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем — + # по правилу владельца заявка живёт, пока окно не откроется или + # пока её не обработают вручную. Интерфейс в этот момент блокирует + # кнопку автодозвона и объясняет причину (autocallAvailability.js). + log.info( + f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — " + "заявка остаётся в очереди до открытия окна" ) - mark_queue_processed(conn, q["queue_id"]) attempt_map[group_id] = attempt_no + 1 return pushed diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index 1d95781..2cc03d8 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -8,6 +8,7 @@ import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; import { describeCallProcess } from "../../services/autocallProcess"; +import { isAutocallPossible } from "../../services/autocallAvailability"; const OUTCOME_META = { confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, @@ -147,6 +148,19 @@ export const AutocallStatusCard = ({ order, userRole }) => { return dayOk && hourOk; }, [recentQueue, callAfter, settings]); + // Можно ли вообще выбрать автодозвон по этому заказу? Считаем по окну + // звонка (те же правила, что у планировщика). Пока окно закрыто — кнопку + // блокируем с объяснением; заявку в очереди НЕ трогаем и НЕ закрываем. + const [availability, setAvailability] = useState(null); + useEffect(() => { + let cancelled = false; + isAutocallPossible(order).then((res) => { + if (!cancelled) setAvailability(res); + }); + return () => { cancelled = true; }; + }, [order?.id, order?.delivery_type, order?.created_at]); + const autocallBlocked = availability ? !availability.possible : false; + const process = useMemo( () => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }), [recentQueue, callAfter, settings, callUnreachable], @@ -264,18 +278,6 @@ export const AutocallStatusCard = ({ order, userRole }) => { {process.title}
{process.hint}
- {process.phase === "unreachable" && ( -
- -
- )} {process.phase === "done" && callAfter && (
@@ -335,29 +337,37 @@ export const AutocallStatusCard = ({ order, userRole }) => { {/* Кнопки */} {canManage && ( -
- - +
+ {autocallBlocked && ( +
+ 🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами. +
+ )} +
+ + +
)}
diff --git a/src/services/autocallAvailability.js b/src/services/autocallAvailability.js new file mode 100644 index 0000000..a4b2042 --- /dev/null +++ b/src/services/autocallAvailability.js @@ -0,0 +1,123 @@ +/** + * @file autocallAvailability.js + * @description Можно ли роботу звонить по заказу прямо сейчас. + * + * Планировщик (scripts/autocall_scheduler.py) перед созданием call-файла + * вычисляет окно звонка: даты, которые робот может предложить клиенту. + * Если окно пусто — звонить не о чем, и кнопку автодозвона в карточке + * нужно блокировать, а не «вечно держать заявку в очереди». + * + * Функции здесь повторяют логику планировщика 1:1 (pickup_window / + * delivery_window) и читают те же настройки из БД (business_schedule_settings), + * так что интерфейс и планировщик всегда согласны друг с другом. + * + * Ключевой принцип (Михаил): заявку «позвонить сейчас» НЕ закрываем никогда. + * Просто не даём выбрать автодозвон, когда звонить не о чем. + */ + +import { supabase } from "../supabaseClient"; + +/** Строка настроек графика: кэш на 5 минут — в рамках сессии диспетчера. */ +let scheduleCache = null; +let scheduleCacheAt = 0; +const SCHEDULE_TTL_MS = 5 * 60 * 1000; + +const loadSchedule = async () => { + const now = Date.now(); + if (scheduleCache && now - scheduleCacheAt < SCHEDULE_TTL_MS) return scheduleCache; + try { + const { data, error } = await supabase + .from("business_schedule_settings") + .select("pickup_days, delivery_days, pickup_same_day_cutoff") + .eq("id", 1) + .maybeSingle(); + if (error) throw error; + scheduleCache = { + pickupDays: (data?.pickup_days || [1, 2, 3, 4, 5]).map((d) => parseInt(d, 10)), + deliveryDays: (data?.delivery_days || [1, 2, 3, 4, 5]).map((d) => parseInt(d, 10)), + // «15:30:00» → 930 минут от полуночи + pickupCutoffMinutes: (() => { + const raw = data?.pickup_same_day_cutoff; + if (!raw) return null; + const parts = String(raw).split(":").map((p) => parseInt(p, 10)); + if (parts.length < 2 || parts.some(Number.isNaN)) return null; + return parts[0] * 60 + parts[1]; + })(), + }; + } catch { + scheduleCache = { + pickupDays: [1, 2, 3, 4, 5], + deliveryDays: [1, 2, 3, 4, 5], + pickupCutoffMinutes: null, + }; + } + scheduleCacheAt = now; + return scheduleCache; +}; + +/** Ближайшие `count` рабочих дней начиная с `start` включительно (1=пн…7=вс). */ +export const workDayWindow = (start, workDays, count) => { + const days = []; + const day = new Date(start); + day.setHours(12, 0, 0, 0); // полдень локальный — без эффектов перехода через полночь + let guard = 0; + while (days.length < count && guard < 30) { + if (workDays.includes(day.getDay() === 0 ? 7 : day.getDay())) days.push(new Date(day)); + day.setDate(day.getDate() + 1); + guard += 1; + } + return days; +}; + +/** Окно самовывоза — повторяет pickup_window планировщика. */ +export const pickupWindow = (readyAt, schedule, now = new Date()) => { + const workDays = schedule?.pickupDays || [1, 2, 3, 4, 5]; + const cutoff = schedule?.pickupCutoffMinutes; + const ready = readyAt ? new Date(readyAt) : new Date(); + ready.setHours(0, 0, 0, 0); + const window = workDayWindow(ready, workDays, 3); + const nowMsk = new Date(now); + const todayStart = new Date(nowMsk); + todayStart.setHours(0, 0, 0, 0); + let firstDay = todayStart; + if (cutoff != null) { + const nowMin = nowMsk.getHours() * 60 + nowMsk.getMinutes(); + if (nowMin >= cutoff) firstDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000); + } + return window.filter((d) => d >= firstDay); +}; + +/** + * Есть ли у робота хоть один день, который он может предложить клиенту. + * `order` — объект группы (delivery_type, created_at / createdAt, sms_sent_at). + */ +export const isAutocallPossible = async (order) => { + if (!order) return { possible: false, reason: "Нет данных заказа" }; + const schedule = await loadSchedule(); + const callType = (order.delivery_type || order.deliveryType || "").toString().toLowerCase() === "pickup" ? "pkp" : "dlv"; + const createdRaw = order.created_at || order.createdAt || order.sms_sent_at || order.smsSentAt; + const readyRaw = createdRaw ? new Date(createdRaw) : new Date(); + + if (callType === "pkp") { + const available = pickupWindow(readyRaw, schedule); + return available.length > 0 + ? { possible: true, availableDays: available.map(fmtDay) } + : { possible: false, reason: "Окно самовывоза закрыто: заказ готов раньше, чем «день готовности + два рабочих дня»" }; + } + + // Доставка: два рабочих дня после готовности, не в день готовности. + const start = new Date(Math.max(todayStart().getTime(), todayStart(readyRaw).getTime() + 24 * 60 * 60 * 1000)); + const window = workDayWindow(start, schedule.deliveryDays, 2); + return window.length > 0 + ? { possible: true, availableDays: window.map(fmtDay) } + : { possible: false, reason: "Нет рабочих дней доставки по графику" }; +}; + +const todayStart = (d = new Date()) => { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +}; + +const fmtDay = (d) => + d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", weekday: "short" }); \ No newline at end of file diff --git a/src/services/autocallProcess.js b/src/services/autocallProcess.js index 8f2a512..61317bf 100644 --- a/src/services/autocallProcess.js +++ b/src/services/autocallProcess.js @@ -139,10 +139,10 @@ export const describeCallProcess = ({ queueItem, callAfter, settings = {}, now = // каждый тик — покажем это честно, а не «в очереди». if (settings.call_unreachable) { return { - phase: "unreachable", + phase: "pending", tone: "warning", - title: "Звонить по этому заказу больше не о чем", - hint: `Робот каждый раз пропускает заявку: по графику у заказа не осталось доступных дней для предложения — ${requested}. Дальше вручную: позвоните сами или подтвердите день в карточке.`, + title: "Заявка в очереди — но роботу пока нечего предложить", + hint: `По графику у заказа нет доступных дней: заявка ждёт открытия окна звонка — ${requested}. Дальше вручную: выберите день в карточке или позвоните сами.`, }; }