feat(autocall): самовывоз — тип звонка pkp/dlv из карточки заказа, выключатель самовывоза, тип в логе и очереди
This commit is contained in:
parent
46a3609233
commit
c4e2c4a181
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue