feat(autocall): звонок клиенту, если SMS не ушло (отсчёт от последней попытки) + статусы «не выбрал день/время» вместо «не нажал цифру»

This commit is contained in:
Hermes 2026-09-16 16:14:34 +02:00
parent 7ab3debbb3
commit 5c98a54895
13 changed files with 94 additions and 52 deletions

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,7 @@ TITLE_SLIDE = {
"sub": "Робот звонит клиенту, согласует день и половину дня — без участия оператора",
"lines": [
"Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,",
"сбросили трубку, не нажали цифру), где что настраивается и что видит менеджер.",
"сбросили трубку, не выбрали день/время), где что настраивается и что видит менеджер.",
"",
"Основано на текущем коде и настройках боевой системы, без обещаний и продажи.",
],
@ -52,7 +52,7 @@ SLIDES = [
"steps": [
("1. Заказ из 1С", "приходит готовым\nпо обмену"),
("2. Ссылка и SMS", "ссылка создаётся\nсама, SMS уходит"),
("3. Пауза 90 минут", "настройка админки:\nстолько ждём ответа"),
("3. Пауза 90 минут", "после SMS или\nпосле попытки, если\nSMS не ушло"),
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
],
@ -187,7 +187,7 @@ SLIDES = [
],
"full": {"title": "Для менеджера это видно в карточке", "accent": ACCENT2, "items": [
"Каждая попытка — отдельная строка истории: время, результат, длительность разговора",
"Статус заказа меняется на «нужен менеджер» — с комментарием «не дозвонились / не нажал»",
"Статус заказа меняется на «нужен менеджер» — с комментарием «не дозвонились / не выбрал»",
"Отдельно красным предупреждаем, если заявка «Позвонить сейчас» висит больше 15 минут",
]},
},
@ -198,8 +198,8 @@ SLIDES = [
"table": {
"rows": [
["Когда положили трубку", "Что запишет робот", "Что увидит менеджер"],
["до выбора дня", "ответил, ничего не нажал", "«взял, не нажал (день)» — попытка зачтена"],
["день выбрал, время не успел", "день выбран, время нет", "«взял, не нажал (время)»"],
["до выбора дня", "ответил, день не выбрал", "«взял, не выбрал день» — попытка зачтена"],
["день выбрал, время не успел", "день выбран, время нет", "«взял, не выбрал время»"],
["выбор подтвердил и сбросил", "слот согласован", "дата и половина дня в заказе, статус «согласовано»"],
["начал менять выбор и сбросил", "остаётся прежний слот", "«правка не доведена» — прежнее согласование в силе"],
],
@ -216,10 +216,10 @@ SLIDES = [
"title": "Когда подключается менеджер",
"sub": "Робот доводит дело до конца сам, но в трёх случаях передаёт человеку",
"cards": [
{"title": "Не нажал цифру 2 раза", "accent": WARN, "items": [
"Робот писал «взял, но не нажал» дважды",
"Заказ помечается «нужен менеджер», комментарий «не дозвонились / не нажал»",
"Порог «не нажал × N» настраивается в админке автодозвона",
{"title": "Не выбрал день/время 2 раза", "accent": WARN, "items": [
"Робот дважды фиксировал «взял, но не выбрал»",
"Заказ помечается «нужен менеджер», комментарий «не дозвонились / не выбрал»",
"Порог «не выбрал × N» настраивается в админке автодозвона",
]},
{"title": "Исчерпаны 5 попыток", "accent": WARN, "items": [
"Каждый звонок — отдельная попытка",
@ -253,11 +253,11 @@ SLIDES = [
"Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней",
"Город исключён целиком или для города выключен канал",
"В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет",
"Меньше 90 минут прошло после SMS со ссылкой",
"Меньше 90 минут прошло после последней попытки отправки SMS: если приглашение ушло — считаем от отправки, если не ушло — от попытки",
]},
],
"full": {"title": "Админка автодозвона: где это переключается", "accent": ACCENT2, "items": [
"Панель «Автодозвон»: тестовый режим, часы и дни звонков, паузы, лимит попыток, порог «не нажал × N»",
"Панель «Автодозвон»: тестовый режим, часы и дни звонков, паузы, лимит попыток, порог «не выбрал × N»",
"Панель «Локации»: для каждого города — звонки включены или выключены (SMS не затрагивается)",
"Панель исключений: номера и префиксы, на которые не звоним, с причиной",
]},
@ -330,12 +330,12 @@ SLIDES = [
"sub": "Одна страница вместо всей презентации",
"cards": [
{"title": "Что делает система", "accent": ACCENT, "full": True, "dense": True, "items": [
"Ждёт 90 минут после SMS и звонит, если клиент не подтвердил день сам",
"Ждёт 90 минут после SMS (или после последней попытки отправки, если SMS не ушло) и звонит, если клиент не подтвердил день сам",
"Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня",
"Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор",
"Граница половины дня — 13:00: звоним после 13:00, значит говорим про вторую половину",
"Повторяет попытки не чаще чем раз в час, максимум 5 раз, только 09:00–20:00 в рабочие дни",
"После двух «взял, но не нажал» или исчерпания попыток передаёт заказ менеджеру",
"После двух «взял, но не выбрал» или исчерпания попыток передаёт заказ менеджеру",
"Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние",
]},
],

View File

@ -85,16 +85,7 @@ def resolve_scenario(created_at, now=None):
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)
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("""
GROUPS_TO_CALL_SQL = """
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.delivery_type, og.town,
@ -104,14 +95,43 @@ def get_groups_to_call(conn, settings):
WHERE l.order_group_id = og.id
AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
(SELECT max(l.created_at) FROM autocall_log l
WHERE l.order_group_id = og.id) as last_call_at
WHERE l.order_group_id = og.id) as last_call_at,
-- От чего считаем паузу перед звонком:
-- * SMS ушло — от времени отправки;
-- * SMS не ушло/не доставлено — от последней попытки отправки
-- (владелец: «если SMS не отправлено — звоним через 90 минут
-- после последней попытки»).
COALESCE(
CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) as call_after
FROM order_groups og
LEFT JOIN LATERAL (
SELECT COALESCE(l.updated_at, l.created_at) AS try_at, l.status AS try_status
FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND l.campaign_type IN ('first_sms', 'second_sms')
ORDER BY COALESCE(l.updated_at, l.created_at) DESC
LIMIT 1
) t ON true
WHERE og.delivery_status = 'pending_confirmation'
AND og.autocall_opt_out = false
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
AND og.sms_sent_at IS NOT NULL
-- Пауза после SMS прошла
AND og.sms_sent_at < NOW() - make_interval(mins => %s)
-- Пауза после SMS (или после неудачной попытки) прошла
AND COALESCE(
CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) IS NOT NULL
AND COALESCE(
CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) < NOW() - make_interval(mins => %s)
-- Не звонили или прошёл интервал повтора
AND (
NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
@ -142,8 +162,20 @@ def get_groups_to_call(conn, settings):
)
-- Самовывоз звоним только если разрешено настройкой
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
ORDER BY og.sms_sent_at ASC
""", (delay_min, retry_min, max_attempts, pickup_enabled))
ORDER BY call_after ASC
"""
def get_groups_to_call(conn, settings):
"""Группы, которым пора звонить."""
delay_min = settings.get("delay_after_sms_minutes", 90)
retry_min = settings.get("retry_interval_minutes", 120)
max_attempts = settings.get("max_attempts", 5)
pickup_enabled = bool(settings.get("pickup_calls_enabled", False))
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(GROUPS_TO_CALL_SQL,
(delay_min, retry_min, max_attempts, pickup_enabled))
return [dict(r) for r in cur.fetchall()]
def is_work_time(settings, now=None):

View File

@ -3,7 +3,7 @@
* @description Бейдж автодозвона в строке реестра доставок.
*
* Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» —
* по ним нельзя было понять, не дозвонились, не нажали цифру или заказ ушёл
* по ним нельзя было понять, не дозвонились, не выбрал клиент день/время или заказ ушёл
* менеджеру. Теперь у каждого состояния своя подпись, взятая из
* AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается).
*
@ -31,7 +31,7 @@ const STYLES = {
const TITLES = {
confirmed: "Клиент согласовал день и половину дня по звонку робота",
no_answer: "Робот звонил, трубку не взяли",
no_input: "Трубку взяли, но цифру в меню не нажали",
no_input: "Трубку взяли, но день/время клиент не выбрал",
manual: "Нужен менеджер: неверный номер или ошибка звонка",
opt_out: "Звонки по этому клиенту запрещены вручную",
excluded: "Исключение: по этому номеру или городу не звоним",

View File

@ -1,7 +1,7 @@
/**
* @file CallStatusBadge.test.jsx
* @description Бейдж автодозвона в реестре доставок: по плашке должно быть понятно,
* что с заказом — не звонили, согласовано, не дозвонились, не нажал цифру,
* что с заказом — не звонили, согласовано, не дозвонились, не выбрал день/время,
* нужен менеджер, запрет или исключение. Без «Автодозвон ❌» на всё подряд.
*/
import { describe, it, expect } from "vitest";
@ -32,7 +32,7 @@ describe("resolveCallBadge — что видно в строке", () => {
expect(new Set(labels).size).toBe(labels.length);
expect(labels[0]).toContain("согласовано");
expect(labels[1]).toContain("не дозвонились");
expect(labels[2]).toContain("не нажал цифру");
expect(labels[2]).toContain("не выбрал день/время");
expect(labels[3]).toContain("нужен менеджер");
});

View File

@ -29,8 +29,8 @@ const OUTCOME_LABELS = {
confirmed_kept_previous: "Правка не доведена",
confirmed_silent: "Молчание",
no_answer: "Не взял",
no_input_day: "Не нажал (день)",
no_input_half: "Не нажал (время)",
no_input_day: "Не выбрал день",
no_input_half: "Не выбрал время",
operator: "Менеджер",
opt_out: "Отказ",
invalid_number: "Неверный номер",

View File

@ -27,8 +27,8 @@ const OUTCOME_META = {
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: "🤐" },
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};
@ -64,8 +64,8 @@ const slotText = (l) => {
return null;
};
const SettingField = ({ label, value, onChange }) => (
<label className="block">
const SettingField = ({ label, value, onChange, hint }) => (
<label className="block" title={hint || undefined}>
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input
type="text"
@ -308,11 +308,16 @@ export const AutocallChannelTab = () => {
{/* Интервалы и попытки */}
<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">
<SettingField label="Пауза после SMS (мин)" value={settings.delay_after_sms_minutes} onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)} />
<SettingField
label="Пауза после SMS / попытки (мин)"
value={settings.delay_after_sms_minutes}
hint="Столько ждём и после отправленного SMS, и после последней неудачной попытки отправки: если приглашение не ушло, робот всё равно позвонит."
onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)}
/>
<SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} />
<SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} />
<SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} />
<SettingField label="«Не нажал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
<SettingField label="«Не выбрал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
<SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} />
</div>

View File

@ -18,8 +18,8 @@ const OUTCOME_META = {
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: "🤐" },
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};

View File

@ -124,6 +124,13 @@ export const SmsProblemNoticeView = ({ summary, onClose, className = "" }) => {
<div className={`mt-2 text-xs ${summary.kind === "resolved" ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}`}>
{hint}
</div>
{summary.kind !== "resolved" && (
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
Автодозвон из-за этого не останавливается: робот позвонит клиенту через паузу после последней
попытки отправки — столько же, сколько настроено в «Пауза после SMS (мин)».
</div>
)}
</div>
);
};

View File

@ -14,8 +14,8 @@ const OUTCOME_META = {
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: "🤐" },
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};

View File

@ -109,10 +109,8 @@ const AUTOCALL_OUTCOME_LABELS = {
confirmed_silent: { label: "Автодозвон: подтвердил молчанием", tone: "ok", icon: "🤖" },
confirmed_kept_previous:{ label: "Автодозвон: клиент правил выбор", tone: "warning", icon: "🤖" },
no_answer: { label: "Автодозвон: не взял трубку", tone: "danger", icon: "🤖" },
no_input_day: { label: "Автодозвон: не нажал (день)", tone: "warning", icon: "🤖" },
no_input_half: { label: "Автодозвон: не нажал (время)", tone: "warning", icon: "🤖" },
operator: { label: "Автодозвон: клиент попросил менеджера", tone: "warning", icon: "🤖" },
opt_out: { label: "Автодозвон: клиент отказался от звонков", tone: "danger", icon: "🤖" },
no_input_day: { label: "Автодозвон: не выбрал день", tone: "warning", icon: "🤖" },
no_input_half: { label: "Автодозвон: не выбрал время", tone: "warning", icon: "🤖" },
invalid_number: { label: "Автодозвон: неверный номер", tone: "danger", icon: "🤖" },
error: { label: "Автодозвон: ошибка звонка", tone: "danger", icon: "🤖" },
};

View File

@ -345,7 +345,7 @@ export const AUTOCALL_STATUS_META = {
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
no_input: { label: "Не нажал в меню", short: "не нажал цифру", tone: "warning", icon: "🤐" },
no_input: { label: "Не выбрал день/время", short: "не выбрал день/время", tone: "warning", icon: "🤐" },
manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" },
@ -356,7 +356,7 @@ export const AUTOCALL_STATUS_OPTIONS = [
{ value: "not_called", label: "Не звонили" },
{ value: "confirmed", label: "Согласовано" },
{ value: "no_answer", label: "Не дозвонились" },
{ value: "no_input", label: "Не нажал в меню" },
{ value: "no_input", label: "Не выбрал день/время" },
{ value: "manual", label: "Нужен менеджер" },
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
{ value: "excluded", label: "Не звоним (исключение)" },