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

414 lines
28 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Автодозвон 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`).
### Что делает
```sql
-- 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'ом):
```json
{
"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. Выбор кандидатов:
```sql
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` (НОВАЯ, минимальная):
```sql
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` для канала:
```js
[
{ 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-включения.