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"])
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
pushed += 1
|
pushed += 1
|
||||||
else:
|
else:
|
||||||
# Звонить не о чем: окно звонка закрыто (например, заказ готов
|
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
|
||||||
# раньше, чем «сегодня + 2 рабочих дня» самовывоза). Закрываем
|
# по правилу владельца заявка живёт, пока окно не откроется или
|
||||||
# заявку навсегда — иначе она молча крутится в очереди, и в
|
# пока её не обработают вручную. Интерфейс в этот момент блокирует
|
||||||
# карточке вечно висит «в очереди, позвоним в течение минуты».
|
# кнопку автодозвона и объясняет причину (autocallAvailability.js).
|
||||||
log.warning(
|
log.info(
|
||||||
f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто, "
|
f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — "
|
||||||
"заявка закрыта — роботу нечего предлагать"
|
"заявка остаётся в очереди до открытия окна"
|
||||||
)
|
)
|
||||||
mark_queue_processed(conn, q["queue_id"])
|
|
||||||
attempt_map[group_id] = attempt_no + 1
|
attempt_map[group_id] = attempt_no + 1
|
||||||
return pushed
|
return pushed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||||
import { describeCallProcess } from "../../services/autocallProcess";
|
import { describeCallProcess } from "../../services/autocallProcess";
|
||||||
|
import { isAutocallPossible } from "../../services/autocallAvailability";
|
||||||
|
|
||||||
const OUTCOME_META = {
|
const OUTCOME_META = {
|
||||||
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
||||||
|
|
@ -147,6 +148,19 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
return dayOk && hourOk;
|
return dayOk && hourOk;
|
||||||
}, [recentQueue, callAfter, settings]);
|
}, [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(
|
const process = useMemo(
|
||||||
() => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }),
|
() => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }),
|
||||||
[recentQueue, callAfter, settings, callUnreachable],
|
[recentQueue, callAfter, settings, callUnreachable],
|
||||||
|
|
@ -264,18 +278,6 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
<span>{process.title}</span>
|
<span>{process.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</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 && (
|
{process.phase === "done" && callAfter && (
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
|
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
|
||||||
|
|
@ -335,11 +337,18 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
{canManage && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCallNow}
|
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"
|
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
|
{sending
|
||||||
|
|
@ -359,6 +368,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
|
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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) {
|
if (settings.call_unreachable) {
|
||||||
return {
|
return {
|
||||||
phase: "unreachable",
|
phase: "pending",
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
title: "Звонить по этому заказу больше не о чем",
|
title: "Заявка в очереди — но роботу пока нечего предложить",
|
||||||
hint: `Робот каждый раз пропускает заявку: по графику у заказа не осталось доступных дней для предложения — ${requested}. Дальше вручную: позвоните сами или подтвердите день в карточке.`,
|
hint: `По графику у заказа нет доступных дней: заявка ждёт открытия окна звонка — ${requested}. Дальше вручную: выберите день в карточке или позвоните сами.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue