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}. Дальше вручную: выберите день в карточке или позвоните сами.`,
};
}