fix(autocall): заявку «позвонить сейчас» не закрываем — при закрытом окне звонка блокируем кнопку автодозвона с объяснением (по правилу владельца)
This commit is contained in:
parent
dacd43165c
commit
e35b3d885f
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 +337,18 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
|
||||
{/* Кнопки */}
|
||||
{canManage && (
|
||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||
<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}
|
||||
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
|
||||
|
|
@ -359,6 +368,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
@ -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}. Дальше вручную: выберите день в карточке или позвоните сами.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue