From 0d8e0f9d4002bb3ee7095fc9f5df9f66607e3735 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 2 Sep 2026 11:10:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(autocall):=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B8=D0=B7=20UI=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20=E2=80=94=20autoca?= =?UTF-8?q?ll=5Fqueue=20nullable=20+=20is=5Ftest=5Fcall,=20TestCallPanel?= =?UTF-8?q?=20=D1=81=20=D0=BE=D0=BA=D0=BD=D0=BE=D0=BC=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/autocall_scheduler.py | 51 +++-- .../admin/autocall/AutocallChannelTab.jsx | 7 + .../admin/autocall/TestCallPanel.jsx | 198 ++++++++++++++++++ .../20260902_autocall_queue_test.sql | 14 ++ 4 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 src/components/admin/autocall/TestCallPanel.jsx create mode 100644 supabase/migrations/20260902_autocall_queue_test.sql diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 073dc95..01b678c 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -136,15 +136,19 @@ def normalize_phone(phone): clean = "7" + clean return clean -def build_call_file(group, settings, attempt_no): +def build_call_file(group, settings, attempt_no, is_test_call=False): """Формирует содержимое call-файла.""" phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = normalize_phone(phone) # Формат набора: 0 + номер без ведущей 7 dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone - test_mode = settings.get("test_mode", True) - if test_mode: + # Тест-звонок из UI: звоним ровно на введённый из UI номер (settings.test_mode не важен), + # но помечаем was_test_mode=1, чтобы в логе и в БД было видно, что это тест. + test_mode = bool(settings.get("test_mode", True)) + if is_test_call: + test_mode = True + 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 @@ -162,7 +166,7 @@ def build_call_file(group, settings, attempt_no): "Archive: yes", f"Setvar: SCENARIO={scenario}", f"Setvar: CALLTYPE={call_type}", - f"Setvar: GROUP_ID={group['id']}", + f"Setvar: GROUP_ID={group.get('id') or ''}", f"Setvar: ATTEMPT={attempt_no}", f"Setvar: CUSTOMER_PHONE={phone}", f"Setvar: API_URL={API_URL}", @@ -172,10 +176,11 @@ def build_call_file(group, settings, attempt_no): ] return "\n".join(lines) -def push_call_file(group, settings, attempt_no): +def push_call_file(group, settings, attempt_no, is_test_call=False): """Кладёт call-файл на АТС через SSH.""" - content = build_call_file(group, settings, attempt_no) - filename = f"ss-{group['id'][:8]}-{int(time.time())}.call" + content = build_call_file(group, settings, attempt_no, is_test_call) + gid = str(group.get("id") or "test")[:8] + filename = f"ss-{gid}-{int(time.time())}.call" # Пишем файл на АТС через sshpass + cat cmd = [ "sshpass", "-p", ATS_PASS, @@ -199,22 +204,23 @@ def get_queued_calls(conn): """Ручные запросы «Позвонить сейчас» из autocall_queue.""" with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" - SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario + SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call FROM autocall_queue q WHERE q.processed_at IS NULL ORDER BY q.requested_at ASC """) rows = [dict(r) for r in cur.fetchall()] - # Подтянем имя/тип доставки из order_groups для call-файла - if rows: - ids = tuple(r["order_group_id"] for r in rows) + # Подтянем телефон из order_groups для звонков по конкретному заказу + # (для test-call order_group_id может быть NULL — номер берём из q.phone) + ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"]) + if ids: cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized FROM order_groups og WHERE og.id IN %s """, (ids,)) og = {r["id"]: r for r in cur.fetchall()} for r in rows: - g = og.get(r["order_group_id"]) + g = og.get(r["order_group_id"]) if r["order_group_id"] else None if g: r["customer_phone_normalized"] = g.get("customer_phone_normalized") r.setdefault("customer_phone", g.get("customer_phone")) @@ -237,14 +243,27 @@ def process_queue(conn, settings): pushed = 0 for q in queued: group_id = q["order_group_id"] + is_test = bool(q.get("is_test_call")) + # Номер: для теста — из q.phone; для заказа — подтянут в customer_phone через get_queued_calls + phone = q.get("phone") or q.get("customer_phone") or "" + if not phone: + log.warning(f"Queue item {q['queue_id']} has no phone, skip") + continue if group_id not in attempt_map: - with conn.cursor() as cur: - cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,)) - attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1 + if group_id: + with conn.cursor() as cur: + cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,)) + attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1 + else: + attempt_map[group_id] = 1 attempt_no = attempt_map[group_id] g = dict(q) g["id"] = group_id - if push_call_file(g, settings, attempt_no): + g["customer_phone"] = phone + # Для теста не подставляем customer_phone_normalized (нет заказа) + if is_test: + g["customer_phone_normalized"] = None + if push_call_file(g, settings, attempt_no, is_test_call=is_test): mark_queue_processed(conn, q["queue_id"]) pushed += 1 attempt_map[group_id] = attempt_no + 1 diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx index 551eaa1..5e1df6e 100644 --- a/src/components/admin/autocall/AutocallChannelTab.jsx +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -8,6 +8,8 @@ import { useNavigate } from "react-router-dom"; import { Panel } from "../../UI/Panel"; import { Badge } from "../../UI/Badge"; import { supabase } from "../../../supabaseClient"; +import { useAuth } from "../../../context/AuthContext"; +import { TestCallPanel } from "./TestCallPanel"; // ── Кампании звонков ───────────────────────────────────────────────────────── const CAMPAIGNS = [ @@ -89,6 +91,8 @@ const FilterButton = ({ active, onClick, children }) => ( export const AutocallChannelTab = () => { const navigate = useNavigate(); + const { user } = useAuth(); + const isMegaAdmin = user?.role === "mega_admin"; const [activeCampaign, setActiveCampaign] = useState("autocall_slot"); const [settings, setSettings] = useState(null); const [allSettings, setAllSettings] = useState({}); @@ -339,6 +343,9 @@ export const AutocallChannelTab = () => { )} + {/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */} + {isMegaAdmin && } + {/* ── Журнал звонков ─────────────────────────────────────────────── */}
diff --git a/src/components/admin/autocall/TestCallPanel.jsx b/src/components/admin/autocall/TestCallPanel.jsx new file mode 100644 index 0000000..4a8faf3 --- /dev/null +++ b/src/components/admin/autocall/TestCallPanel.jsx @@ -0,0 +1,198 @@ +/** + * @file TestCallPanel.jsx + * @description Блок «Тестовый звонок» во вкладке Автодозвон (только для мегаадмина). + * Мегаадмин вводит произвольный номер → пишем в autocall_queue (is_test_call=true, + * без привязки к заказу) → планировщик создаёт call-файл на АТС клиента. + * Показываем живой статус: в очереди → дозвон → результат выбора (окно результата). + * Результат читаем из autocall_log по номеру с момента запуска (поллинг). + */ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { supabase } from "../../../supabaseClient"; +import { Panel } from "../../UI/Panel"; +import { Badge } from "../../UI/Badge"; + +const OUTCOME_META = { + in_progress: { label: "Идёт звонок…", tone: "neutral", icon: "🔔" }, + queued: { label: "В очереди…", tone: "neutral", icon: "⏳" }, + confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, + confirmed_kept_previous: { label: "Правка не доведена", tone: "warning", icon: "🔁" }, + confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" }, + no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, + no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, + no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, + operator: { label: "Просил менеджера", tone: "danger", icon: "👤" }, + opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" }, + invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, + error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, +}; + +const DAY_LABEL = { 1: "завтра", 2: "послезавтра" }; +const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" }; + +const fmtPhone = (phone) => { + if (!phone) return "—"; + const d = String(phone).replace(/\D/g, ""); + if (d.length === 11 && d.startsWith("7")) { + return `+7 (${d.slice(1, 4)}) ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9)}`; + } + return phone; +}; + +const fmtTime = (ts) => { + if (!ts) return ""; + try { + return new Date(ts).toLocaleString("ru-RU", { + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } catch { return ""; } +}; + +export const TestCallPanel = () => { + const [phone, setPhone] = useState(""); + const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error + const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста + const [startedAt, setStartedAt] = useState(null); + const pollRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + }, []); + + const startCall = async () => { + const digits = String(phone || "").replace(/\D/g, ""); + if (digits.length < 10) { setStatus("error"); setResult({ outcome: "error", note: "Введите корректный номер (не менее 10 цифр)" }); return; } + stopPolling(); + setStatus("queueing"); + setResult({ outcome: "queued" }); + const now = new Date().toISOString(); + setStartedAt(now); + try { + // Ручной тест-звонок: без привязки к заказу, is_test_call=true. + // Мегаадмин-запрос — прямой insert (RLS на autocall_queue выключен). + const { error } = await supabase.from("autocall_queue").insert({ + order_group_id: null, + phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)), + scenario: "A", + is_test_call: true, + }); + if (error) throw error; + setStatus("queued"); + setResult({ outcome: "queued", note: "Запрос поставлен в очередь. Планировщик создаст звонок." }); + // Начинаем поллить результат по нашему номеру с момента запуска + pollRef.current = setInterval(() => pollResult(now), 3000); + } catch (e) { + console.error("Test call queue error:", e); + setStatus("error"); + setResult({ outcome: "error", note: `Не удалось поставить в очередь: ${e.message}` }); + } + }; + + const pollResult = useCallback(async (since) => { + const norm = "7" + String(phone || "").replace(/\D/g, "").slice(-10); + try { + const { data, error } = await supabase + .from("autocall_log") + .select("*") + .eq("was_test_mode", true) + .eq("customer_phone", norm) + .gte("created_at", since) + .order("created_at", { ascending: false }) + .limit(1); + if (error) throw error; + if (data && data.length) { + const row = data[0]; + setResult(row); + setStatus(row.outcome === "confirmed" || row.outcome === "confirmed_kept_previous" || row.outcome === "confirmed_silent" ? "done" + : (["no_answer","no_input_day","no_input_half","operator","opt_out","invalid_number","error"].includes(row.outcome) ? "done" : "dialing")); + // Как только есть финальный исход (не in_progress) — стоп + if (["confirmed","confirmed_kept_previous","confirmed_silent","no_answer","no_input_day","no_input_half","operator","opt_out","invalid_number","error"].includes(row.outcome)) { + stopPolling(); + } + } else { + setStatus((s) => (s === "dialing" ? "dialing" : "queued")); + } + } catch (e) { + // глуши, поллинг продолжит + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phone, stopPolling]); + + useEffect(() => () => stopPolling(), [stopPolling]); + + const m = OUTCOME_META[result?.outcome] || OUTCOME_META.in_progress; + const slotText = result?.dtmf_day || result?.dtmf_half + ? `${DAY_LABEL[result.dtmf_day] || ""}${result.dtmf_day && result.dtmf_half ? ", " : ""}${HALF_LABEL[result.dtmf_half] || ""}`.trim() || "—" + : null; + + return ( + +
+

📞 Тестовый звонок

+ 🧪 мегаадмин +
+

+ Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню. +

+ +
+ setPhone(e.target.value)} + placeholder="+7 9xx xxx-xx-xx" + disabled={status === "queueing" || status === "queued" || status === "dialing"} + className="flex-1 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-50" + /> + + {(status === "queued" || status === "dialing" || status === "done") && ( + + )} +
+ + {/* ── Окно результата ─────────────────────────────────────────────── */} + {(status !== "idle" || result) && ( +
+
+ {m.icon} + {m.label} + {(status === "queued" || status === "dialing") && ( + + )} +
+ {result?.note &&
{result.note}
} + {result?.order_group_id && ( +
Заказ: {String(result.order_group_id).slice(0, 8)}…
+ )} + {(result?.dtmf_day || result?.dtmf_half) && ( +
+ Выбранный слот: + {slotText} +
+ )} + {result?.changed && ( +
Клиент правил выбор (сначала подтвердил, потом поменял)
+ )} + {result?.created_at && ( +
{fmtTime(result.created_at)}
+ )} +
+ )} +
+ ); +}; diff --git a/supabase/migrations/20260902_autocall_queue_test.sql b/supabase/migrations/20260902_autocall_queue_test.sql new file mode 100644 index 0000000..6a6c5d2 --- /dev/null +++ b/supabase/migrations/20260902_autocall_queue_test.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- SuperSam: Автодозвон — тестовый звонок из UI (мегаадмин) +-- 02.09.2026. Аддитивная. +-- order_group_id становится nullable: тестовый звонок на произвольный +-- номер не привязан к заказу. При обработке номер берётся из q.phone. +-- Добавляем колонку is_test_call, чтобы отличать ручную тест-проверку. +-- ============================================================ + +alter table public.autocall_queue alter column order_group_id drop not null; + +alter table public.autocall_queue add column if not exists is_test_call boolean not null default false; + +comment on column public.autocall_queue.is_test_call + is 'true — тест звонка из UI (мегаадмин), order_group_id может быть NULL.';