feat(autocall): самовывоз — тип звонка pkp/dlv из карточки заказа, выключатель самовывоза, тип в логе и очереди

This commit is contained in:
Hermes 2026-09-11 10:53:53 +02:00
parent 46a3609233
commit c4e2c4a181
6 changed files with 91 additions and 11 deletions

View File

@ -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,

View File

@ -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})")

View File

@ -285,6 +285,27 @@ export const AutocallChannelTab = () => {
)}
</div>
{/* Самовывоз — отдельный выключатель: звонки клиентам, которые забирают со склада */}
<div className={`mb-4 rounded-xl border p-3 ${settings.pickup_calls_enabled ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" : "border-[var(--color-border)] bg-[var(--color-surface)]"}`}>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">🏪 Самовывоз</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{settings.pickup_calls_enabled
? "Робот звонит и по самовывозу: предлагает забрать завтра или послезавтра"
: "Звонки по самовывозу выключены — звоним только по доставке"}
</div>
</div>
<button
type="button"
onClick={() => updateSetting("pickup_calls_enabled", !settings.pickup_calls_enabled)}
className={`relative h-7 w-12 shrink-0 rounded-full transition ${settings.pickup_calls_enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.pickup_calls_enabled ? "left-[22px]" : "left-0.5"}`} />
</button>
</label>
</div>
{/* Интервалы и попытки */}
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -408,6 +429,7 @@ export const AutocallChannelTab = () => {
<div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(l.customer_phone)}
{l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>}
<div className="text-[10px] text-[var(--color-text-muted)]">{l.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</div>
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div>

View File

@ -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 = () => {
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
</p>
{/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */}
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">Сценарий:</span>
{[
{ key: "dlv", label: "🚚 Доставка" },
{ key: "pkp", label: "🏪 Самовывоз" },
].map((t) => (
<button
key={t.key}
type="button"
onClick={() => setCallType(t.key)}
disabled={status === "queueing" || status === "queued" || status === "dialing"}
className={`rounded-full px-3 py-1 text-xs font-medium transition disabled:opacity-50 ${
callType === t.key
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="tel"
@ -227,6 +252,7 @@ export const TestCallPanel = () => {
return (
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">{t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</span>
<span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span>
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>

View File

@ -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 }) => {
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
{c.call_type === "pkp" && <span className="text-[10px] text-[var(--color-text-muted)]">🏪 самовывоз</span>}
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
</div>
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && (

View File

@ -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,