diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index b681f09..56615b4 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -383,8 +383,13 @@ def resolve_call_type(group): return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv" -def build_call_file(group, settings, attempt_no, is_test_call=False): - """Формирует содержимое call-файла.""" +def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False): + """Формирует содержимое call-файла. + + manual=True — звонок запущен руками из карточки заказа. Логист выбрал + конкретного клиента, поэтому звоним ему, даже если кампания в тестовом + режиме: тестовый режим страхует массовый обзвон, а не ручной запрос. + """ phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = normalize_phone(phone) # Формат набора: 0 + номер без ведущей 7 @@ -395,6 +400,8 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): test_mode = bool(settings.get("test_mode", True)) if is_test_call: test_mode = True + elif manual and group.get("id"): + test_mode = False elif test_mode: test_phone = normalize_phone(settings.get("test_phone", "9067029953")) dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone @@ -453,9 +460,9 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): ] return "\n".join(lines) -def push_call_file(group, settings, attempt_no, is_test_call=False): +def push_call_file(group, settings, attempt_no, is_test_call=False, manual=False): """Кладёт call-файл на АТС через SSH.""" - content = build_call_file(group, settings, attempt_no, is_test_call) + content = build_call_file(group, settings, attempt_no, is_test_call, manual) if content is None: # Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем. log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка") @@ -561,7 +568,7 @@ def process_queue(conn, settings): # Для теста не подставляем customer_phone_normalized (нет заказа) if is_test: g["customer_phone_normalized"] = None - if push_call_file(g, settings, attempt_no, is_test_call=is_test): + if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True): mark_queue_processed(conn, q["queue_id"]) pushed += 1 attempt_map[group_id] = attempt_no + 1 diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index c29528f..e7f65c3 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -3,10 +3,11 @@ * @description Блок «Автодозвон» в карточке заказа. * Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить». */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; +import { describeCallProcess } from "../../services/autocallProcess"; const OUTCOME_META = { confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, @@ -44,9 +45,11 @@ const slotText = (l) => { export const AutocallStatusCard = ({ order, userRole }) => { const [calls, setCalls] = useState([]); + const [queueItem, setQueueItem] = useState(null); + const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(false); const [toggling, setToggling] = useState(false); - const [queued, setQueued] = useState(false); + const [sending, setSending] = useState(false); const canManage = ["mega_admin", "logistician"].includes(userRole); const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false; @@ -72,11 +75,85 @@ export const AutocallStatusCard = ({ order, userRole }) => { } }, [order?.id]); - useEffect(() => { loadCalls(); }, [loadCalls]); + // Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было + // не видно, ушёл ли запрос и что с ним стало. + const loadQueue = useCallback(async () => { + if (!order?.id) return; + try { + const { data } = await supabase + .from("autocall_queue") + .select("id, requested_at, processed_at, is_test_call, call_type, phone") + .eq("order_group_id", order.id) + .order("requested_at", { ascending: false }) + .limit(1); + setQueueItem(data?.[0] || null); + } catch { + // silent + } + }, [order?.id]); + + // Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно, + // когда именно робот возьмёт заявку. + const loadSettings = useCallback(async () => { + try { + const { data } = await supabase + .from("sms_campaign_settings") + .select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days") + .eq("campaign_type", "autocall_slot") + .maybeSingle(); + setSettings(data || null); + } catch { + // silent + } + }, []); + + useEffect(() => { + loadCalls(); + loadQueue(); + loadSettings(); + }, [loadCalls, loadQueue, loadSettings]); + + // Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё). + const callAfter = useMemo(() => { + if (!queueItem?.requested_at) return null; + const from = new Date(queueItem.requested_at).getTime() - 1000; + const to = from + 15 * 60 * 1000; + return ( + calls.find((c) => { + const at = new Date(c.created_at).getTime(); + return at >= from && at <= to; + }) || null + ); + }, [calls, queueItem]); + + // Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс. + const recentQueue = useMemo(() => { + if (!queueItem?.requested_at) return null; + return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null; + }, [queueItem]); + + const process = useMemo( + () => describeCallProcess({ queueItem: recentQueue, callAfter, settings: settings || {} }), + [recentQueue, callAfter, settings], + ); + + const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing"; + + // Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд. + useEffect(() => { + if (!processing || !recentQueue) return undefined; + const age = Date.now() - new Date(recentQueue.requested_at).getTime(); + if (age > 15 * 60 * 1000) return undefined; + const timer = setInterval(() => { + loadQueue(); + loadCalls(); + }, 7000); + return () => clearInterval(timer); + }, [processing, recentQueue, loadQueue, loadCalls]); const handleCallNow = async () => { if (!order?.id) return; - setQueued(true); + setSending(true); try { // Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn). // Сценарий планировщик определит сам по дню добавления группы @@ -91,10 +168,11 @@ export const AutocallStatusCard = ({ order, userRole }) => { : {}), }); if (error) throw error; - setTimeout(() => setQueued(false), 3000); + await loadQueue(); } catch (e) { console.error("Queue call error:", e); - setQueued(false); + } finally { + setSending(false); } }; @@ -150,10 +228,45 @@ export const AutocallStatusCard = ({ order, userRole }) => { )} + {/* Состояние ручного звонка: заявка, набор, результат, «когда позвонят» */} + {process && ( +
+
+ {processing && ( +
+
{process.hint}
+ {process.phase === "done" && callAfter && ( +
+ + {(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome} + + {fmtTime(callAfter.created_at)} + {slotText(callAfter) && {slotText(callAfter)}} + {callAfter.duration_sec > 0 && {Math.round(callAfter.duration_sec)} сек} +
+ )} +
+ )} + {/* История звонков — как «История SMS» */} {calls.length > 0 ? (
История звонков
+ {loading &&
обновляем…
}
{calls.map((c, idx) => { const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" }; @@ -199,10 +312,16 @@ export const AutocallStatusCard = ({ order, userRole }) => {