28 KiB
Автодозвон SuperSam — детальный план реализации (БД → API → планировщик → UI)
Дата: 01.09.2026 · Статус АТС: диалоговая машина и голос Vivienne проверены живьём. Контракт реализации: каждая фаза — отдельный PR, сначала dev (dev.mkn8n.ru), prod — только после проверки Михаилом. Mobile-first: реестр и журнал — подписанные строки на viewport 390px, таблица только desktop. Термины/строки в UI и SQL — ровно те, что уже используются в приложении («Первая половина дня» и т.п.).
Фаза 0 — Заморозка контрактов (нулевой код, только согласование)
Цель: зафиксировать значения, вокруг которых всё строится, чтобы не разъехались UI, БД и AGI.
Файл: docs/autocall-contract.md (новый, в репо).
Содержимое:
- Кампания-ключ:
campaign_type='autocall_slot'(первый сценарий). Резерв ключей:autocall_storage,autocall_reminder,autocall_confirm_morning. - Сценарии звонка:
A(день+время),B(время, день=завтра),C(подтверждение выбранного, молчание=да). - Outcome-значения
autocall_log.outcome(единственный enum, на него смотрит UI):confirmed,confirmed_kept_previous,confirmed_silent,no_answer,no_input_day,no_input_half,operator,opt_out,invalid_number,error. - DTMF-семантика:
dtmf_day: 1=завтра, 2=послезавтра;dtmf_half: 1=первая половина дня, 2=вторая. Больше ничего. 0/9/* — не сохраняются как ответ, а формируют outcome. - Записи в
order_groupsприconfirmed*:delivery_date/pickup_date,delivery_time/pickup_time_slot,delivery_date_source='autocall',delivery_status='agreed',notification_status='confirmed',called=true,called_at,called_comment='Согласовано роботом: …'. - Эскалация:
operator,opt_out,invalid_number,no_input×no_input_escalate_after,attempts≥max_attempts→notification_status='manual_required'(плюсautocall_opt_out=trueтолько для 9). - Тайминг: отсчёт от
sms_sent_at, паузаdelay_after_sms_minutes(90),retry_interval_minutes(120), попытки в деньattempts_per_day(3), следующий деньnext_day_attempt_times(10:00,14:00), окноwork_hours_start/end9–20,work_daysпн–сб. Все — из БД, не хардкод. - Тест:
test_mode=true→ наборtest_phone;was_test_mode=trueпишем в лог.
Проверка: файл открыт и подтверждён Михаилом в PR-описании.
Фаза 1 — База данных (миграция dev, потом prod)
Файлы
- Создать
supabase/migrations/20260901_autocall_base.sql(модель: один файл, применяем как остальные DDL — см. правило: DDL толькоpsql -U supabase_admin).
Что делает
-- 1) лог звонков
create table if not exists public.autocall_log (
id bigint generated always as identity primary key,
order_group_id uuid references public.order_groups(id) on delete set null,
campaign_type text not null default 'autocall_slot',
customer_phone text not null,
attempt_no smallint not null,
scenario text not null check (scenario in ('A','B','C')),
call_started_at timestamptz not null,
answered_at timestamptz,
duration_sec smallint,
sip_code smallint,
channel_state text,
dtmf_day smallint check (dtmf_day in (1,2) or dtmf_day is null),
dtmf_half smallint check (dtmf_half in (1,2) or dtmf_half is null),
changed boolean not null default false,
outcome text not null check (outcome in (
'confirmed','confirmed_kept_previous','confirmed_silent',
'no_answer','no_input_day','no_input_half',
'operator','opt_out','invalid_number','error')),
was_test_mode boolean not null default false,
asterisk_uniqueid text,
created_at timestamptz not null default now()
);
create index if not exists idx_autocall_log_group on public.autocall_log(order_group_id, created_at desc);
create index if not exists idx_autocall_log_phone on public.autocall_log(customer_phone, created_at desc);
create index if not exists idx_autocall_log_outcome on public.autocall_log(outcome, created_at desc);
create index if not exists idx_autocall_log_uniq on public.autocall_log(asterisk_uniqueid);
comment on table public.autocall_log is 'Лог робо-звонков (автодозвон через АТС клиента). UI читает outcome.';
grant select on public.autocall_log to authenticated;
-- 2) order_groups
alter table public.order_groups add column if not exists autocall_opt_out boolean not null default false;
create index if not exists idx_order_groups_autocall_optout
on public.order_groups(autocall_opt_out) where autocall_opt_out = true;
-- 3) новые колонки настроек (если их ещё нет)
alter table public.sms_campaign_settings add column if not exists delay_after_sms_minutes int;
alter table public.sms_campaign_settings add column if not exists retry_interval_minutes int;
alter table public.sms_campaign_settings add column if not exists attempts_per_day int;
alter table public.sms_campaign_settings add column if not exists no_input_escalate_after int;
alter table public.sms_campaign_settings add column if not exists next_day_attempt_times text;
alter table public.sms_campaign_settings add column if not exists call_timeout_seconds int;
-- 4) строка кампании (в боевом режиме — выключена и тестовая)
insert into public.sms_campaign_settings
(campaign_type, enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days,
max_attempts, delay_after_sms_minutes, retry_interval_minutes, attempts_per_day,
no_input_escalate_after, next_day_attempt_times, call_timeout_seconds)
values
('autocall_slot', false, true, '9067029953', 9, 20, '1,2,3,4,5,6',
5, 90, 120, 3,
2, '10:00,14:00', 30)
on conflict (campaign_type) do nothing;
-- 5) представление флагов для реестра (без N+1)
create or replace view public.order_group_comms_flags as
select
og.id,
og.autocall_opt_out,
og.delivery_date_source,
og.notification_status,
og.delivery_status,
(select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome,
(select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed,
(select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts,
(select count(*) from public.autocall_log l where l.order_group_id = og.id
and l.outcome in ('no_input_day','no_input_half')) as no_input_count
from public.order_groups og;
grant select on public.order_group_comms_flags to authenticated;
Применение и проверка
- Dev:
docker exec supabase-db psql -U supabase_admin -d postgres -f /tmp/20260901_autocall_base.sql - Проверки:
\d autocall_log;select campaign_type from sms_campaign_settings where campaign_type='autocall_slot';select * from order_group_comms_flags limit 1.
Фаза 2 — Edge function autocall-result
Файлы
- Создать
supabase/functions/autocall-result/index.ts - Общие helpers:
supabase/functions/_shared/(используем существующие cors/service-role паттерны изconfirm-delivery-choice).
Контракт endpoint'а
POST /functions/v1/autocall-result
Headers: x-webhook-secret: <WEBHOOK_SECRET> (тот же стиль, что у dadata-suggest).
Body (JSON ровно в этом виде; AGI шлёт curl'ом):
{
"asterisk_uniqueid":"1788283049.23",
"order_group_id":"uuid-or-null",
"campaign_type":"autocall_slot",
"customer_phone":"79067029953",
"attempt_no":2,
"scenario":"A",
"call_started_at":"2026-09-01T20:17:45+03:00",
"answered_at":"2026-09-01T20:17:45+03:00",
"duration_sec":65,
"sip_code":200,
"channel_state":"Up",
"dtmf_day":1,"dtmf_half":2,"changed":true,
"outcome":"confirmed",
"was_test_mode":true
}
Логика функции (по шагам)
- Валидация секрета и полей;
outcome— только из enum (Фаза 0). - Идемпотентность: upsert по
asterisk_uniqueid(повторный curl не плодит строки). - Вставка в
autocall_log. - Если
order_group_idнет — найти последний активный заказ поcustomer_phone(status not in ('closed','cancelled'),delivery_status='pending_confirmation'). - При
confirmed | confirmed_kept_previous | confirmed_silent:- вычислить
delivery_date= (завтра/послезавтра от даты звонка, по Europe/Moscow); delivery_time='Первая половина дня'|'Вторая половина дня'(точно эти строки);- pickup: вместо delivery_* пишем
pickup_date,pickup_time_slotприdelivery_type='pickup'; - обновить
order_groups: поля из Фазы 0 п.5 +next_notification_check_at=null.
- вычислить
- При
operator|opt_out|invalid_number:notification_status='manual_required',called=true,called_comment=...; дляopt_outещёautocall_opt_out=true. - При
no_answer|no_input_day|no_input_half: посчитать уже сделанные попытки; приattempt_no >= max_attemptsилиno-input >= no_input_escalate_after→manual_required, иначе оставитьnotification_status='link_ready'(планировщик назначит следующую). auth.uid()пуст → вorder_historysource проставится «Система» (триггер уже работает); добавитьmetadata.source='autocall'через update-поле (если триггер поддерживает; иначе отдельный insert вorder_history).- Ответ:
{ok:true}/{ok:false,error}.
Тесты (ручные, dev)
- curl с тестовым uniqueid → строка в
autocall_log, заказ обновился, в UI бейдж «🤖 слот от робота». - Повторный curl с тем же uniqueid → дубликата нет.
Фаза 3 — Планировщик (systemd на проде, dev — ручной запуск)
Файлы
- Создать
scripts/autocall_campaign.py - Создать
scripts/autocall_campaign.service - Создать
scripts/autocall_campaign.timer - Конфиг-реюз: импортировать helper'ы из
sms_run_with_db.sh(резолв IP supabase-db).
Алгоритм тика (запуск каждые 10 мин)
- Загрузить настройки
autocall_slotизsms_campaign_settings. Еслиenabled=false→ выход. - Выбор кандидатов:
В test_mode —select id, customer_phone, delivery_type, first_sms_sent_at from order_groups where notification_status='link_ready' and delivery_status='pending_confirmation' and autocall_opt_out=false and first_sms_sent_at is not null and first_sms_sent_at <= now() - (delay_after_sms_minutes || ' minutes')::interval and coalesce((select max(attempt_no) from autocall_log l where l.order_group_id=order_groups.id),0) < max_attempts and (next_notification_check_at is null or next_notification_check_at <= now());customer_phoneзаменяем наtest_phone. - Проверка окна:
work_hours_start <= now < work_hours_end, день недели ∈work_days; иначе — отложить (next_notification_check_at= ближайшее начало окна). - Сценарий: если дата «готовности» = сегодня →
A, иначеB. (C планировщик пока не выбирает — сценарий C включается отдельной задачей позже.) - Расчёт номера попытки:
attempt_no = колв-во строк в autocall_log + 1; распределение по дню/след.дню: еслиattempt_no <= attempts_per_day→ сейчас; иначе поnext_day_attempt_times. - Генерация call-файла в
/tmp/ss-<group>-<attempt>.call:Channel: PJSIP/0<phone10>@mmedia Context: SS-Autocall-Slot Extension: s Priority: 1 MaxRetries: 0 WaitTime: 30 Archive: yes Setvar: SCENARIO=A Setvar: CALLTYPE=dlv Setvar: GROUP_ID=<uuid> Setvar: ATTEMPT=<n> Setvar: API_URL=https://<prod>/functions/v1/autocall-result Setvar: API_KEY=<webhook-secret> Setvar: TEST_MODE=0|1 - Передача на АТС:
scp → /tmp(атомарно mv в/var/spool/asterisk/outgoing/), ssh-кред только на сервере (никаких ключей в репо — в/opt/supersam/.envлокально). - Логирование тика в
sms_campaign_log-стиле своей строкойcampaign_type='autocall_slot'(техническая метка «тик был») — опционально, для отладки таймера. - Обработка ручных запросов: таблица
autocall_queue(НОВАЯ, минимальная):
Кнопка «Позвонить сейчас» пишет сюда; тик забираетcreate table if not exists public.autocall_queue ( id bigint generated always as identity primary key, order_group_id uuid not null, phone text not null, scenario text not null default 'A', requested_by uuid, requested_at timestamptz not null default now(), processed_at timestamptz );processed_at is null, создаёт call-файл, ставитprocessed_at. (Так ssh никогда не попадает в edge fn.)
Юнит-проверки (без настоящих звонков)
- Запустить скрипт с
--dry-run: в консоль печатает список кандидатов и текст call-файла. test_mode=true,test_phone=<твой>→ один звонок тебе.
Таймер
# autocall_campaign.timer
[Timer]
OnCalendar=*:0/10
Persistent=true
# autocall_campaign.service
[Service]
Type=oneshot
ExecStart=/opt/supersam/scripts/sms_run_with_db.sh /opt/supersam/scripts/autocall_campaign.py
Фаза 4 — Диалплан и AGI-связь с API (изменения на АТС, с показом Паше)
ss-dialog.agiдополнить: в конце (после определения итоговогоOUTCOME) — curl наAPI_URLс телом из Фазы 2. Переменные приходят из call-файла (GROUP_ID,ATTEMPT,TEST_MODE). Покрыть: потерю сети (curl timeout 5s, не валить звонок — результат всё равно в/tmp/ss-result-*.txt).[SS-Autocall-Slot]— additive контекст (диф Паше до применения).pbx_spool.so— попросить постоянную загрузку (иначе ночной reboot 03:33 сносит).
Фаза 5 — UI: оболочка «Коммуникации» (вкладки)
Файлы
- Создать
src/components/admin/CommunicationsPanel.jsx— подключает вкладки. - Перенести содержимое
SmsCampaignPanel.jsxвsrc/components/admin/sms/SmsChannelTab.jsx(почти copy-paste, имя файла сохранить в git как rename). src/pages/DashboardPage.jsx:- импорт
CommunicationsPanelвместоSmsCampaignPanel; MEGA_ADMIN_NAV:{ key:"sms_campaign", label:"Коммуникации", description:"SMS, автодозвон и аналитика обращений." }(key не трогаем — совместимость?tab=sms_campaign).- роут:
if (activeSection === "sms_campaign") return <CommunicationsPanel/>.
- импорт
Поведение вкладок
- Состояние:
searchParams.channel∈sms | autocall | analytics, дефолтsms. - UI: таблетки (по стилю существующего период-селектора в
SmsCampaignStats). - На мобильном: вкладки в одну строку с горизонтальным скроллом, подписи короткие.
Проверка (dev)
- Прямой переход
/dashboard?tab=sms_campaign&channel=autocallоткрывает нужную вкладку. - Все 4 SMS-карточки, статистика, журнал — без регрессий.
- На 390px — ничего не ломается.
Фаза 6 — UI: вкладка «Автодозвон»
Файлы
- Создать
src/components/admin/autocall/AutocallChannelTab.jsx— карточки, настройки, журнал. - Создать
src/components/admin/autocall/AutocallLogTable.jsx— таблица/строки + фильтры + поиск. - Создать
src/components/admin/autocall/AutocallStats.jsx— графики (recharts, как SmsCampaignStats).
Карточки кампаний звонков
CAMPAIGNS для канала:
[
{ key:"autocall_slot", label:"Согласование слота", icon:"🔊", live:true },
{ key:"autocall_storage", label:"Платное хранение (голосом)",icon:"📦", live:false },
{ key:"autocall_reminder", label:"Напоминание накануне", icon:"⏰", live:false },
{ key:"autocall_confirm_morning", label:"Подтверждение утром", icon:"☀️", live:false },
]
Неработающие — карточка disabled с бейджем «скоро». Рабочая — по шаблону SMS-карточек: тоггл вкл/выкл, бейдж (⏸/🧪/🚀), мини-статистика Всего / Дозвон / Согласовали, кнопки ▶/🧪/🔄.
Настройки autocall_slot (форма, все поля из Фазы 0 п.7)
Группы:
- Режим: тест/боевой + тестовый номер.
- Интервалы:
delay_after_sms_minutes,retry_interval_minutes,attempts_per_day,max_attempts,next_day_attempt_times. - Эскалация:
no_input_escalate_after. - Окно: часы (селекты как у SMS) и дни-таблетки.
- Telegram chat ID для алертов недозвона.
Кнопка «Сохранить» — тот же паттерн (
update sms_campaign_settings). По умолчаниюenabled=false,test_mode=true.
Журнал звонков
- Данные:
supabase.from("autocall_log").select("*").order("created_at desc").limit(200). - Таблица (desktop):
Заказ | Клиент/телефон | Попытка | Время | Длит. | Исход | Слот. - Mobile (<640px): подписанные строки:
🔊 Володя · +7 906 702-99-53 Исход: ✅ Согласовал (попытка 2) Слот: завтра, первая половина дня 01.09.26, 20:18 - Бейджи исходов: тон/иконка/текст из маппинга (Фаза 0 п.3);
confirmed*— зелёные,no_answer— нейтраль,no_input*— warning,operator/opt_out/invalid_number/error— danger. - Фильтры-chips: все + 10 исходов (значение = outcome). Поиск: по номеру заказа/телефону/имени (общий helper с SMS-журналом).
- Клик по строке →
/dashboard/group/<order_group_id>. - Пагинация 20/стр, автообновление 30с (тоггл, как у SMS).
Фаза 7 — Бейджи снаружи (реестр и логистика)
Файлы
src/components/logistics/LogisticsReadinessBoard.jsx— колонка/строка бейджа.src/hooks/useOrderGroups.js— подтягиватьorder_group_comms_flagsодним запросом join'ом (или вторым запросом по списку id — проще, 1 запрос).
Бейдж (приоритет сверху вниз — один наружу)
| Условие | Бейдж | Тон |
|---|---|---|
autocall_opt_out |
🚫 отказ от звонков | danger |
last_call_outcome='invalid_number' |
❌ неверный номер | danger |
notification_status='manual_required' + были звонки |
👤 менеджеру | warning |
no_input_count ≥ 2 |
🤐 не нажимает | warning |
call_attempts ≥ 3 + последний no_answer |
📵 не дозвонились | warning |
delivery_date_source='autocall' + last_call_changed |
🤖✏️ слот от робота (правился) | info |
delivery_date_source='autocall' |
🤖 слот от робота | info |
Реализация: компонент src/components/UI/CallStatusBadge.jsx (props: флаги), рендерится:
- desktop — отдельная колонка «Дозвон» в таблице групп;
- mobile — иконка в подписанной строке (рядом с телефоном).
Фильтры реестра
Chips над таблицей (мультивыбор): 📵 Не дозвонились, 🤐 Не нажимают, 👤 Менеджеру (после робота), 🤖 Согласовано роботом, 🚫 Отказ от звонков. Фильтрация — на клиенте по уже загруженным флагам (скрываем строки), без новых запросов.
Дашборд логистика и водителя
В карточках доставок — мелкая пометка «🤖» возле времени слота при delivery_date_source='autocall'. Без фильтров, только визуальная пометка.
Фаза 8 — Карточка заказа: блок «Автодозвон»
Файл
- Создать
src/components/orders/AutocallStatusCard.jsx; подключить вDeliverySetDetailPanel.jsxрядом сSmsStatusCard.
Содержимое
- Заголовок: «📞 Автодозвон» + бейдж-итог (успех/в работе/менеджеру/отказ).
- Слот: если
confirmed*— «Завтра, первая половина дня» (плюс пометка «согласовано роботом»). - История: до 5 строк из
autocall_logэтого заказа (иконка исхода, время, попытка). - Если
last_call_changed=true— подсказка: «Клиент правил выбор. При сомнении — перезвоните». - Кнопки (роли
mega_admin,logistician):[📞 Позвонить сейчас]→ insert вautocall_queue(см. Фаза 3, п.9), кнопка заменяется на «В очереди ✓».[🚫 Не звонить]/[🔔 Вернуть в автодозвон]→ toggleautocall_opt_out.
- В
order_historyуже попадает (триггер); доработка:metadata.source='autocall'— вывести в таймлайне как «Автодозвон», если UI таймлайна отображает source.
Фаза 9 — Вкладка «Аналитика»
Файл
- Создать
src/components/admin/analytics/CommsAnalyticsTab.jsx.
Блоки
- Сводка каналов за период (7/14/30/всё — селектор как в SmsCampaignStats):
- SMS: отправлено/доставлено/конверсия переходов по ссылке (если считаем — иначе «н/д»),
- Звонки: попыток/дозвонились/согласовали/эскалации,
- Итог: «Согласовано автоматикой» vs
manual_requiredвсего.
- Воронка автодозвона (bar): SMS→звонок1→…→звонок5→согласовано/менеджеру.
- Исходы (donut из
autocall_log.outcome). - По дням (bar: попыток/дозвон/согласовано) + конверсия согласования (line).
- «Согласовали с N-й попытки» (bar) — покажет, нужна ли 4–5 попытка.
- Минуты: «время на линии всего / средний звонок» (sum/avg
duration_sec) — данные для разговора с Пашей о тарифе Miranda. - Экспорт CSV текущего фильтра журнала звонков и сводки (кнопка «⬇ CSV», генерим на клиенте Blob).
Все запросы — только чтение autocall_log/sms_campaign_log за период; без новых таблиц.
Фаза 10 — Раскатка и приёмка
- Dev прогон: миграция → edge fn → планировщик (dry-run) → UI (вкладки, журнал, бейджи, карточка, аналитика) на 390px и desktop.
- Тестовый заказ: создать заказ, прогнать цикл SMS → (не выбирает слот) → звонок на
test_phone→ согласование → проверить карточку, бейдж в реестре, таймлайн, аналитику. - Боевая проба на твоём номере:
test_mode=true, реальный заказ → ты нажимаешь 1/2 → всё сошлось. - Перенос prod: миграция, edge fn, планировщик (таймер включён, кампания
enabled=false), UI-деплой (бамп SW cache). - Включение prod — только по твоей команде: сначала
test_mode=true(боевой prod, но звонит тебе), потом выключаем test_mode. - Гипигиена: вернуть
approvalsв manual, секреты — в/opt/supersam/.env, доки:docs/autocall-contract.mdактуален.
Риски и вопросы (держим перед глазами)
- Reboot АТС 03:33 → Паша ставит
pbx_spool.soв автозагрузку (диф показан до). - Тариф Miranda не считается из CDR → своя статистика минут (Фаза 9, п.6).
- Отказ от звонков — юр. требование: озвучка «9» уже в диалоге; счётчик opt-out на карточке кампании.
- Никаких ssh из edge fn — ручные звонки только через
autocall_queue+ планировщик. - Совместимость URL:
?tab=sms_campaignсохранён как key; label меняем на «Коммуникации».
Оценка по объёму
- Фазы 1–4 (БД+API+планировщик+АТС-curl): ~0,5–1 день.
- Фазы 5–9 (UI): ~1–1,5 дня.
- Фаза 10 (прогоны): несколько часов. Итого ≈ 2–2,5 рабочих дня до prod-включения.