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.title}
+
+
{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 }) => {