fix(autocall): заявку «позвонить сейчас» не закрываем — при закрытом окне звонка блокируем кнопку автодозвона с объяснением (по правилу владельца)

This commit is contained in:
Hermes 2026-09-16 16:47:49 +02:00
parent dacd43165c
commit e35b3d885f
4 changed files with 178 additions and 46 deletions

View File

@ -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

View File

@ -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 }) => {
<span>{process.title}</span>
</div>
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</div>
{process.phase === "unreachable" && (
<div className="mt-1.5">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing}
className="rounded-lg border border-[var(--color-warning)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-warning)] hover:bg-[rgba(245,158,11,0.08)] disabled:opacity-50"
>
{sending ? "Отправляем заявку…" : "📞 Всё равно позвонить"}
</button>
</div>
)}
{process.phase === "done" && callAfter && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
@ -335,29 +337,37 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{/* Кнопки */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing || optOut}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{sending
? "Отправляем заявку…"
: process?.phase === "dialing"
? "📞 Звоним…"
: processing
? "✓ Заявка в очереди"
: "📞 Позвонить сейчас"}
</button>
<button
type="button"
onClick={handleToggleOptOut}
disabled={toggling}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
>
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
</button>
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
{autocallBlocked && (
<div className="mb-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing || optOut || autocallBlocked}
title={autocallBlocked ? (availability?.reason || "Автодозвон недоступен") : undefined}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{sending
? "Отправляем заявку…"
: process?.phase === "dialing"
? "📞 Звоним…"
: processing
? "✓ Заявка в очереди"
: "📞 Позвонить сейчас"}
</button>
<button
type="button"
onClick={handleToggleOptOut}
disabled={toggling}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
>
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
</button>
</div>
</div>
)}
</div>

View File

@ -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" });

View File

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