feat(autocall): история звонков в карточке — как у SMS (попытки, слот, сценарий, длительность)

This commit is contained in:
Hermes 2026-09-10 11:28:39 +02:00
parent 04f78f7367
commit c7a35cdd29
3 changed files with 69 additions and 31 deletions

View File

@ -73,6 +73,18 @@ def load_settings(conn):
}
return dict(row)
def resolve_scenario(created_at, now=None):
"""Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
со следующего дня → B (день фиксирован = завтра, только половина дня)."""
if not created_at:
return "A"
now = now or datetime.now(timezone.utc)
msk_tz = timezone(timedelta(hours=3))
created = created_at.astimezone(msk_tz)
current = now.astimezone(msk_tz)
return "A" if created.date() == current.date() else "B"
def get_groups_to_call(conn, settings):
"""Группы, которым пора звонить."""
delay_min = settings.get("delay_after_sms_minutes", 90)
@ -84,7 +96,7 @@ def get_groups_to_call(conn, settings):
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.autocall_opt_out, og.created_at,
(SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id) as attempt_count,
(SELECT count(*) FROM autocall_log l
@ -169,7 +181,9 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
scenario = group.get("scenario", "A") # выбор дня + времени
# Сценарий: в день добавления группы → A (завтра/послезавтра),
# со следующего дня → B (только завтра + половина дня).
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
call_type = "dlv"
lines = [
@ -232,7 +246,7 @@ 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
SELECT og.id, og.customer_phone, og.customer_phone_normalized, og.created_at
FROM order_groups og WHERE og.id IN %s
""", (ids,))
og = {r["id"]: r for r in cur.fetchall()}
@ -241,6 +255,7 @@ def get_queued_calls(conn):
if g:
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"))
return rows

View File

@ -62,7 +62,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
.select("*")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(5);
.limit(10);
if (error) throw error;
setCalls(data || []);
} catch (e) {
@ -78,11 +78,12 @@ export const AutocallStatusCard = ({ order, userRole }) => {
if (!order?.id) return;
setQueued(true);
try {
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn)
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
// Сценарий планировщик определит сам по дню добавления группы
// (день добавления → A: завтра/послезавтра; позже → B: только завтра).
const { error } = await supabase.from("autocall_queue").insert({
order_group_id: order.id,
phone: order.customer_phone || order.customerPhone || "",
scenario: "A",
});
if (error) throw error;
setTimeout(() => setQueued(false), 3000);
@ -137,22 +138,44 @@ export const AutocallStatusCard = ({ order, userRole }) => {
</div>
)}
{/* Попытки */}
{/* Счётчик попыток — как у SMS «Попыток отправки: N» */}
{calls.length > 0 && (
<div className="mb-2 text-[11px] text-[var(--color-text-muted)]">
Попыток звонков: {calls.length}
</div>
)}
{/* История звонков — как «История SMS» */}
{calls.length > 0 ? (
<div className="mt-1 border-t border-[var(--color-border)] pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
<div className="space-y-1.5">
{calls.map((c) => {
{calls.map((c, idx) => {
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
const slot = slotText(c);
const attemptNo = calls.length - idx;
return (
<div key={c.id} className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px]">
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
<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>
{slot && <span className="text-[var(--color-text)]">{slot}</span>}
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
</div>
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>}
{slot && <span>Выбрано: {slot}</span>}
{c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>}
{c.scenario === "B" && <span>только завтра</span>}
{c.scenario === "A" && <span>завтра/послезавтра</span>}
{c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>}
</div>
)}
</div>
);
})}
</div>
</div>
) : (
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
)}

View File

@ -123,21 +123,21 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
const smsStatusOptions = [
{ value: "all", label: "Статус SMS" },
{ value: "not_sent", label: "SMS: не отправлено" },
{ value: "sent", label: "SMS: отправлено" },
{ value: "delivered", label: "SMS: доставлено" },
{ value: "error", label: "SMS: ошибка" },
{ value: "not_sent", label: "Не отправлено" },
{ value: "sent", label: "Отправлено" },
{ value: "delivered", label: "Доставлено" },
{ value: "error", label: "Ошибка" },
];
const autocallStatusOptions = [
{ value: "all", label: "Статус автодозвона" },
{ value: "not_called", label: "Автодозвон: не звонили" },
{ value: "confirmed", label: "Автодозвон: согласовано" },
{ value: "no_answer", label: "Автодозвон: не дозвонились" },
{ value: "no_input", label: "Автодозвон: не нажал в меню" },
{ value: "manual", label: "Автодозвон: требует ручного управления" },
{ value: "opt_out", label: "Автодозвон: отказ от звонков" },
{ value: "excluded", label: "Автодозвон: не звоним (исключение)" },
{ value: "not_called", label: "Не звонили" },
{ value: "confirmed", label: "Согласовано" },
{ value: "no_answer", label: "Не дозвонились" },
{ value: "no_input", label: "Не нажал в меню" },
{ value: "manual", label: "Требует ручного управления" },
{ value: "opt_out", label: "Отказ от звонков" },
{ value: "excluded", label: "Не звоним (исключение)" },
];
return (