feat(autocall): тестовый звонок из UI для мегаадмина — autocall_queue nullable + is_test_call, TestCallPanel с окном результата
This commit is contained in:
parent
4ed7f64a37
commit
0d8e0f9d40
|
|
@ -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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
||||
{isMegaAdmin && <TestCallPanel />}
|
||||
|
||||
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
||||
<Panel className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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.';
|
||||
Loading…
Reference in New Issue