supersam/docs/superpowers/plans/2026-09-01-autocall-integra...

28 KiB
Raw Blame History

Автодозвон 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 (новый, в репо).

Содержимое:

  1. Кампания-ключ: campaign_type='autocall_slot' (первый сценарий). Резерв ключей: autocall_storage, autocall_reminder, autocall_confirm_morning.
  2. Сценарии звонка: A (день+время), B (время, день=завтра), C (подтверждение выбранного, молчание=да).
  3. 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.
  4. DTMF-семантика: dtmf_day: 1=завтра, 2=послезавтра; dtmf_half: 1=первая половина дня, 2=вторая. Больше ничего. 0/9/* — не сохраняются как ответ, а формируют outcome.
  5. Записи в 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='Согласовано роботом: …'.
  6. Эскалация: operator, opt_out, invalid_number, no_input×no_input_escalate_after, attempts≥max_attempts → notification_status='manual_required' (плюс autocall_opt_out=true только для 9).
  7. Тайминг: отсчёт от 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/end 9–20, work_days пн–сб. Все — из БД, не хардкод.
  8. Тест: 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
}

Логика функции (по шагам)

  1. Валидация секрета и полей; outcome — только из enum (Фаза 0).
  2. Идемпотентность: upsert по asterisk_uniqueid (повторный curl не плодит строки).
  3. Вставка в autocall_log.
  4. Если order_group_id нет — найти последний активный заказ по customer_phone (status not in ('closed','cancelled'), delivery_status='pending_confirmation').
  5. При 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.
  6. При operator|opt_out|invalid_number: notification_status='manual_required', called=true, called_comment=...; для opt_out ещё autocall_opt_out=true.
  7. При no_answer|no_input_day|no_input_half: посчитать уже сделанные попытки; при attempt_no >= max_attempts или no-input >= no_input_escalate_after → manual_required, иначе оставить notification_status='link_ready' (планировщик назначит следующую).
  8. auth.uid() пуст → в order_history source проставится «Система» (триггер уже работает); добавить metadata.source='autocall' через update-поле (если триггер поддерживает; иначе отдельный insert в order_history).
  9. Ответ: {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 мин)

  1. Загрузить настройки autocall_slot из sms_campaign_settings. Если enabled=false → выход.
  2. Выбор кандидатов:
    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());
    
    В test_mode — customer_phone заменяем на test_phone.
  3. Проверка окна: work_hours_start <= now < work_hours_end, день недели ∈ work_days; иначе — отложить (next_notification_check_at = ближайшее начало окна).
  4. Сценарий: если дата «готовности» = сегодня → A, иначе B. (C планировщик пока не выбирает — сценарий C включается отдельной задачей позже.)
  5. Расчёт номера попытки: attempt_no = колв-во строк в autocall_log + 1; распределение по дню/след.дню: если attempt_no <= attempts_per_day → сейчас; иначе по next_day_attempt_times.
  6. Генерация 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
    
  7. Передача на АТС: scp → /tmp (атомарно mv в /var/spool/asterisk/outgoing/), ssh-кред только на сервере (никаких ключей в репо — в /opt/supersam/.env локально).
  8. Логирование тика в sms_campaign_log-стиле своей строкой campaign_type='autocall_slot' (техническая метка «тик был») — опционально, для отладки таймера.
  9. Обработка ручных запросов: таблица 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 (изменения на АТС, с показом Паше)

  1. ss-dialog.agi дополнить: в конце (после определения итогового OUTCOME) — curl на API_URL с телом из Фазы 2. Переменные приходят из call-файла (GROUP_ID, ATTEMPT, TEST_MODE). Покрыть: потерю сети (curl timeout 5s, не валить звонок — результат всё равно в /tmp/ss-result-*.txt).
  2. [SS-Autocall-Slot] — additive контекст (диф Паше до применения).
  3. 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), кнопка заменяется на «В очереди ✓».
    • [🚫 Не звонить] / [🔔 Вернуть в автодозвон] → toggle autocall_opt_out.
  • В order_history уже попадает (триггер); доработка: metadata.source='autocall' — вывести в таймлайне как «Автодозвон», если UI таймлайна отображает source.

Фаза 9 — Вкладка «Аналитика»

Файл

  • Создать src/components/admin/analytics/CommsAnalyticsTab.jsx.

Блоки

  1. Сводка каналов за период (7/14/30/всё — селектор как в SmsCampaignStats):
    • SMS: отправлено/доставлено/конверсия переходов по ссылке (если считаем — иначе «н/д»),
    • Звонки: попыток/дозвонились/согласовали/эскалации,
    • Итог: «Согласовано автоматикой» vs manual_required всего.
  2. Воронка автодозвона (bar): SMS→звонок1→…→звонок5→согласовано/менеджеру.
  3. Исходы (donut из autocall_log.outcome).
  4. По дням (bar: попыток/дозвон/согласовано) + конверсия согласования (line).
  5. «Согласовали с N-й попытки» (bar) — покажет, нужна ли 4–5 попытка.
  6. Минуты: «время на линии всего / средний звонок» (sum/avg duration_sec) — данные для разговора с Пашей о тарифе Miranda.
  7. Экспорт CSV текущего фильтра журнала звонков и сводки (кнопка «⬇ CSV», генерим на клиенте Blob).

Все запросы — только чтение autocall_log/sms_campaign_log за период; без новых таблиц.


Фаза 10 — Раскатка и приёмка

  1. Dev прогон: миграция → edge fn → планировщик (dry-run) → UI (вкладки, журнал, бейджи, карточка, аналитика) на 390px и desktop.
  2. Тестовый заказ: создать заказ, прогнать цикл SMS → (не выбирает слот) → звонок на test_phone → согласование → проверить карточку, бейдж в реестре, таймлайн, аналитику.
  3. Боевая проба на твоём номере: test_mode=true, реальный заказ → ты нажимаешь 1/2 → всё сошлось.
  4. Перенос prod: миграция, edge fn, планировщик (таймер включён, кампания enabled=false), UI-деплой (бамп SW cache).
  5. Включение prod — только по твоей команде: сначала test_mode=true (боевой prod, но звонит тебе), потом выключаем test_mode.
  6. Гипигиена: вернуть 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-включения.