diff --git a/docs/ats/ss-dialog.agi b/docs/ats/ss-dialog.agi index 4fca788..eeda485 100644 --- a/docs/ats/ss-dialog.agi +++ b/docs/ats/ss-dialog.agi @@ -84,6 +84,7 @@ send_result () { \"customer_phone\":\"${CUSTOMER_PHONE:-}\", \"attempt_no\":${ATTEMPT:-1}, \"scenario\":\"$SCENARIO\", + \"call_type\":\"${TYPE:-dlv}\", \"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"duration_sec\":0, diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 3398339..794a803 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -69,7 +69,7 @@ def load_settings(conn): "delay_after_sms_minutes": 90, "retry_interval_minutes": 120, "attempts_per_day": 3, "max_attempts": 5, "no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00", - "call_timeout_seconds": 30, + "call_timeout_seconds": 30, "pickup_calls_enabled": False, } return dict(row) @@ -91,12 +91,13 @@ def get_groups_to_call(conn, settings): retry_min = settings.get("retry_interval_minutes", 120) max_attempts = settings.get("max_attempts", 5) no_input_escalate = settings.get("no_input_escalate_after", 2) + pickup_enabled = bool(settings.get("pickup_calls_enabled", False)) with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized, og.notification_status, og.delivery_status, og.sms_sent_at, - og.autocall_opt_out, og.created_at, + og.autocall_opt_out, og.created_at, og.delivery_type, (SELECT count(*) FROM autocall_log l WHERE l.order_group_id = og.id) as attempt_count, (SELECT count(*) FROM autocall_log l @@ -120,8 +121,10 @@ def get_groups_to_call(conn, settings): -- Не превышен лимит попыток AND (SELECT count(*) FROM autocall_log l WHERE l.order_group_id = og.id) < %s + -- Самовывоз звоним только если разрешено настройкой + AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s) ORDER BY og.sms_sent_at ASC - """, (delay_min, retry_min, max_attempts)) + """, (delay_min, retry_min, max_attempts, pickup_enabled)) return [dict(r) for r in cur.fetchall()] def is_work_time(settings, now=None): @@ -165,6 +168,19 @@ def get_exclusion_match(conn, phone): """, (phone, phone)) return cur.fetchone() +def resolve_call_type(group): + """Тип звонка: pkp (самовывоз) или dlv (доставка). + + Приоритет — явный call_type (ручной/тестовый запрос из очереди): его + выбирает логист. Если не задан — определяем по типу получения заказа. + От типа зависит озвучка (ru/ss_*_pkp vs ru/ss_*_dlv). + """ + ct = (group.get("call_type") or "").strip().lower() + if ct in ("dlv", "pkp"): + return ct + 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-файла.""" phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") @@ -184,7 +200,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): # Сценарий: в день добавления группы → A (завтра/послезавтра), # со следующего дня → B (только завтра + половина дня). scenario = group.get("scenario") or resolve_scenario(group.get("created_at")) - call_type = "dlv" + call_type = resolve_call_type(group) lines = [ f"Channel: PJSIP/{dial}@mmedia", @@ -235,7 +251,8 @@ 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, q.is_test_call + SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call, + q.call_type FROM autocall_queue q WHERE q.processed_at IS NULL ORDER BY q.requested_at ASC @@ -246,7 +263,8 @@ def get_queued_calls(conn): 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, og.created_at + SELECT og.id, og.customer_phone, og.customer_phone_normalized, + og.created_at, og.delivery_type FROM order_groups og WHERE og.id IN %s """, (ids,)) og = {r["id"]: r for r in cur.fetchall()} @@ -256,6 +274,7 @@ def get_queued_calls(conn): r["customer_phone_normalized"] = g.get("customer_phone_normalized") r.setdefault("customer_phone", g.get("customer_phone")) r.setdefault("created_at", g.get("created_at")) + r.setdefault("delivery_type", g.get("delivery_type")) return rows @@ -327,7 +346,7 @@ def backfill_no_answer(conn, settings): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT q.id, q.order_group_id, q.phone, q.is_test_call, - q.requested_at, q.processed_at + q.requested_at, q.processed_at, q.call_type FROM autocall_queue q WHERE q.processed_at IS NOT NULL AND q.processed_at < NOW() - make_interval(mins => 10) @@ -356,10 +375,11 @@ def backfill_no_answer(conn, settings): # Телефон из заказа (или нормализованный из заявки для pure-теста) phone = normalize_phone(q["phone"] or "") group_created = None + group_dtype = None if gid: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" - SELECT customer_phone_normalized, customer_phone, created_at + SELECT customer_phone_normalized, customer_phone, created_at, delivery_type FROM order_groups WHERE id = %s """, (gid,)) grow = cur.fetchone() or {} @@ -367,6 +387,7 @@ def backfill_no_answer(conn, settings): or normalize_phone(grow.get("customer_phone") or "") or phone) group_created = grow.get("created_at") + group_dtype = grow.get("delivery_type") # По исключённым номерам звонка не было — строку не пишем # (статус «исключение» и так показывается из настроек). if get_exclusion_match(conn, phone): @@ -379,14 +400,15 @@ def backfill_no_answer(conn, settings): else: attempt_no = 1 test_mode = bool(q["is_test_call"] or settings.get("test_mode", True)) + call_type = resolve_call_type({"call_type": q.get("call_type"), "delivery_type": group_dtype}) with conn.cursor() as cur: cur.execute(""" INSERT INTO autocall_log (order_group_id, customer_phone, attempt_no, scenario, - call_started_at, outcome, was_test_mode, channel_state) - VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer') + call_started_at, outcome, was_test_mode, channel_state, call_type) + VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer', %s) """, (gid, phone, attempt_no, scenario, - q["processed_at"] or q["requested_at"], test_mode)) + q["processed_at"] or q["requested_at"], test_mode, call_type)) conn.commit() backfilled += 1 log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})") diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx index f792a65..d0cdbea 100644 --- a/src/components/admin/autocall/AutocallChannelTab.jsx +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -285,6 +285,27 @@ export const AutocallChannelTab = () => { )} + {/* Самовывоз — отдельный выключатель: звонки клиентам, которые забирают со склада */} +
+ +
+ {/* Интервалы и попытки */}
📊 Интервалы и попытки
@@ -408,6 +429,7 @@ export const AutocallChannelTab = () => {
{fmtPhone(l.customer_phone)} {l.was_test_mode &&
🧪 тест
} +
{l.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}
{l.attempt_no}
{l.duration_sec ? `${l.duration_sec}с` : "—"}
diff --git a/src/components/admin/autocall/TestCallPanel.jsx b/src/components/admin/autocall/TestCallPanel.jsx index dd8c1c5..821c866 100644 --- a/src/components/admin/autocall/TestCallPanel.jsx +++ b/src/components/admin/autocall/TestCallPanel.jsx @@ -49,6 +49,7 @@ const fmtTime = (ts) => { export const TestCallPanel = () => { const [phone, setPhone] = useState(""); + const [callType, setCallType] = useState("dlv"); // dlv = доставка, pkp = самовывоз const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста const [startedAt, setStartedAt] = useState(null); @@ -90,6 +91,7 @@ export const TestCallPanel = () => { order_group_id: null, phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)), scenario: "A", + call_type: callType, is_test_call: true, }); if (error) throw error; @@ -152,6 +154,29 @@ export const TestCallPanel = () => { Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.

+ {/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */} +
+ Сценарий: + {[ + { key: "dlv", label: "🚚 Доставка" }, + { key: "pkp", label: "🏪 Самовывоз" }, + ].map((t) => ( + + ))} +
+
{ return (
{fmtPhone(t.customer_phone)} + {t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"} {tm.icon} {tm.label} {slot && · {slot}} · {fmtTime(t.created_at)} diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index 54be81e..c29528f 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -84,6 +84,11 @@ export const AutocallStatusCard = ({ order, userRole }) => { const { error } = await supabase.from("autocall_queue").insert({ order_group_id: order.id, phone: order.customer_phone || order.customerPhone || "", + // Тип озвучки: по самовывозу робот говорит «забрать», по доставке — «доставить». + // Если тип в объекте не пришёл, планировщик определит его по заказу сам. + ...((order.delivery_type || order.deliveryType) + ? { call_type: (order.delivery_type || order.deliveryType) === "pickup" ? "pkp" : "dlv" } + : {}), }); if (error) throw error; setTimeout(() => setQueued(false), 3000); @@ -159,6 +164,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{fmtTime(c.created_at)} {m.icon} {m.label} + {c.call_type === "pkp" && 🏪 самовывоз} {c.was_test_mode && 🧪 тест}
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && ( diff --git a/supabase/functions/autocall-result/index.ts b/supabase/functions/autocall-result/index.ts index c379f8e..438070c 100644 --- a/supabase/functions/autocall-result/index.ts +++ b/supabase/functions/autocall-result/index.ts @@ -29,6 +29,7 @@ type AutocallBody = { customer_phone?: string; attempt_no?: number; scenario?: string; + call_type?: string; call_started_at?: string; answered_at?: string | null; duration_sec?: number | null; @@ -81,6 +82,7 @@ Deno.serve(async (request) => { const campaignType = body.campaign_type || "autocall_slot"; const attemptNo = body.attempt_no ?? 1; const scenario = body.scenario || "A"; + const callType = body.call_type === "pkp" ? "pkp" : "dlv"; const wasTest = body.was_test_mode ?? false; // ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ── @@ -109,6 +111,7 @@ Deno.serve(async (request) => { customer_phone: body.customer_phone, attempt_no: attemptNo, scenario, + call_type: callType, call_started_at: body.call_started_at || new Date().toISOString(), answered_at: body.answered_at || null, duration_sec: body.duration_sec ?? null,