feat(autocall): тестовый звонок из UI для мегаадмина — autocall_queue nullable + is_test_call, TestCallPanel с окном результата

This commit is contained in:
Hermes 2026-09-02 11:10:06 +02:00
parent 4ed7f64a37
commit 0d8e0f9d40
4 changed files with 254 additions and 16 deletions

View File

@ -136,15 +136,19 @@ def normalize_phone(phone):
clean = "7" + clean clean = "7" + clean
return clean return clean
def build_call_file(group, settings, attempt_no): def build_call_file(group, settings, attempt_no, is_test_call=False):
"""Формирует содержимое call-файла.""" """Формирует содержимое call-файла."""
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
phone = normalize_phone(phone) phone = normalize_phone(phone)
# Формат набора: 0 + номер без ведущей 7 # Формат набора: 0 + номер без ведущей 7
dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone
test_mode = settings.get("test_mode", True) # Тест-звонок из UI: звоним ровно на введённый из UI номер (settings.test_mode не важен),
if 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")) test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone 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", "Archive: yes",
f"Setvar: SCENARIO={scenario}", f"Setvar: SCENARIO={scenario}",
f"Setvar: CALLTYPE={call_type}", 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: ATTEMPT={attempt_no}",
f"Setvar: CUSTOMER_PHONE={phone}", f"Setvar: CUSTOMER_PHONE={phone}",
f"Setvar: API_URL={API_URL}", f"Setvar: API_URL={API_URL}",
@ -172,10 +176,11 @@ def build_call_file(group, settings, attempt_no):
] ]
return "\n".join(lines) 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.""" """Кладёт call-файл на АТС через SSH."""
content = build_call_file(group, settings, attempt_no) content = build_call_file(group, settings, attempt_no, is_test_call)
filename = f"ss-{group['id'][:8]}-{int(time.time())}.call" gid = str(group.get("id") or "test")[:8]
filename = f"ss-{gid}-{int(time.time())}.call"
# Пишем файл на АТС через sshpass + cat # Пишем файл на АТС через sshpass + cat
cmd = [ cmd = [
"sshpass", "-p", ATS_PASS, "sshpass", "-p", ATS_PASS,
@ -199,22 +204,23 @@ def get_queued_calls(conn):
"""Ручные запросы «Позвонить сейчас» из autocall_queue.""" """Ручные запросы «Позвонить сейчас» из autocall_queue."""
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" 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 FROM autocall_queue q
WHERE q.processed_at IS NULL WHERE q.processed_at IS NULL
ORDER BY q.requested_at ASC ORDER BY q.requested_at ASC
""") """)
rows = [dict(r) for r in cur.fetchall()] rows = [dict(r) for r in cur.fetchall()]
# Подтянем имя/тип доставки из order_groups для call-файла # Подтянем телефон из order_groups для звонков по конкретному заказу
if rows: # (для test-call order_group_id может быть NULL — номер берём из q.phone)
ids = tuple(r["order_group_id"] for r in rows) ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
if ids:
cur.execute(""" cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized SELECT og.id, og.customer_phone, og.customer_phone_normalized
FROM order_groups og WHERE og.id IN %s FROM order_groups og WHERE og.id IN %s
""", (ids,)) """, (ids,))
og = {r["id"]: r for r in cur.fetchall()} og = {r["id"]: r for r in cur.fetchall()}
for r in rows: 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: if g:
r["customer_phone_normalized"] = g.get("customer_phone_normalized") r["customer_phone_normalized"] = g.get("customer_phone_normalized")
r.setdefault("customer_phone", g.get("customer_phone")) r.setdefault("customer_phone", g.get("customer_phone"))
@ -237,14 +243,27 @@ def process_queue(conn, settings):
pushed = 0 pushed = 0
for q in queued: for q in queued:
group_id = q["order_group_id"] 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: if group_id not in attempt_map:
if group_id:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,)) cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,))
attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1 attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1
else:
attempt_map[group_id] = 1
attempt_no = attempt_map[group_id] attempt_no = attempt_map[group_id]
g = dict(q) g = dict(q)
g["id"] = group_id 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"]) mark_queue_processed(conn, q["queue_id"])
pushed += 1 pushed += 1
attempt_map[group_id] = attempt_no + 1 attempt_map[group_id] = attempt_no + 1

View File

@ -8,6 +8,8 @@ import { useNavigate } from "react-router-dom";
import { Panel } from "../../UI/Panel"; import { Panel } from "../../UI/Panel";
import { Badge } from "../../UI/Badge"; import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient"; import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext";
import { TestCallPanel } from "./TestCallPanel";
// ── Кампании звонков ───────────────────────────────────────────────────────── // ── Кампании звонков ─────────────────────────────────────────────────────────
const CAMPAIGNS = [ const CAMPAIGNS = [
@ -89,6 +91,8 @@ const FilterButton = ({ active, onClick, children }) => (
export const AutocallChannelTab = () => { export const AutocallChannelTab = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const isMegaAdmin = user?.role === "mega_admin";
const [activeCampaign, setActiveCampaign] = useState("autocall_slot"); const [activeCampaign, setActiveCampaign] = useState("autocall_slot");
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [allSettings, setAllSettings] = useState({}); const [allSettings, setAllSettings] = useState({});
@ -339,6 +343,9 @@ export const AutocallChannelTab = () => {
</Panel> </Panel>
)} )}
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
{isMegaAdmin && <TestCallPanel />}
{/* ── Журнал звонков ─────────────────────────────────────────────── */} {/* ── Журнал звонков ─────────────────────────────────────────────── */}
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@ -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 (
<Panel className="p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Тестовый звонок</h3>
<Badge tone="warning">🧪 мегаадмин</Badge>
</div>
<p className="mb-3 text-xs text-[var(--color-text-muted)]">
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="tel"
value={phone}
onChange={(e) => 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"
/>
<button
onClick={startCall}
disabled={status === "queueing" || status === "queued" || status === "dialing"}
className="rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{status === "queueing" ? "Отправка…" : status === "queued" || status === "dialing" ? "Звоним…" : "🔊 Позвонить"}
</button>
{(status === "queued" || status === "dialing" || status === "done") && (
<button
onClick={() => { stopPolling(); setStatus("idle"); setResult(null); setStartedAt(null); }}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
>
Сбросить
</button>
)}
</div>
{/* ── Окно результата ─────────────────────────────────────────────── */}
{(status !== "idle" || result) && (
<div className={`mt-3 rounded-xl border p-3 text-xs ${
m.tone === "accent" ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: m.tone === "danger" ? "border-[var(--color-danger)] bg-[rgba(239,68,68,0.06)]"
: m.tone === "warning" ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]"
: "border-[var(--color-border)] bg-[var(--color-surface)]"
}`}>
<div className="flex items-center gap-2">
<span className="text-base">{m.icon}</span>
<span className="font-semibold text-[var(--color-text)]">{m.label}</span>
{(status === "queued" || status === "dialing") && (
<span className="ml-auto h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-border)] border-t-[var(--color-accent)]" />
)}
</div>
{result?.note && <div className="mt-1 text-[var(--color-text-muted)]">{result.note}</div>}
{result?.order_group_id && (
<div className="mt-1 text-[var(--color-text-muted)]">Заказ: {String(result.order_group_id).slice(0, 8)}…</div>
)}
{(result?.dtmf_day || result?.dtmf_half) && (
<div className="mt-1.5">
<span className="text-[var(--color-text-muted)]">Выбранный слот: </span>
<span className="font-semibold text-[var(--color-text)]">{slotText}</span>
</div>
)}
{result?.changed && (
<div className="mt-1 text-[var(--color-warning)]">Клиент правил выбор (сначала подтвердил, потом поменял)</div>
)}
{result?.created_at && (
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">{fmtTime(result.created_at)}</div>
)}
</div>
)}
</Panel>
);
};

View File

@ -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.';