diff --git a/docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md b/docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md new file mode 100644 index 00000000..5b6e5b3c --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md @@ -0,0 +1,217 @@ +# Резервный CSV-канал импорта лидов (Путь 2) — дизайн + +**Дата:** 18.05.2026 +**Статус:** утверждён заказчиком +**Эпик:** интеграция с поставщиком crm.bp-gr.ru — резервный канал + +## 1. Цель + +Webhook (Путь 3) — основной живой канал поступления лидов от поставщика crm.bp-gr.ru. +Доказан вживую 18.05.2026 (112 реальных лидов). Но живой провод может оборваться — +сбой сети, смена webhook-URL на стороне поставщика, падение нашего портала. + +CSV-канал (Путь 2) — **страховка**: периодическая сверка отчёта поставщика «Запрос +номеров» с тем, что реально приняли через webhook. Лиды, которых нет в нашей базе, — +подбираются (recovery). Если webhook жив, сверка стабильно находит 0 пропущенных. + +CSV-канал НЕ заменяет webhook и не работает параллельным потоком — он только +**добирает пропущенное** и **сигнализирует**, что webhook теряет лиды. + +## 2. Источник данных + +Отчёт поставщика **«Запрос номеров»** (`/admin/report/index`, self-service dropdown). + +- Формат: **CSV**, разделитель `;`, заголовок `Name;Tag;Phone` — 3 колонки. + - `Name` — имя проекта поставщика, формат `B_` (как в webhook `raw_payload['project']`). + - `Tag` — тег проекта (клиент/рекламодатель). + - `Phone` — номер телефона лида. +- **Нет** `vid` (id лида), **нет** времени отгрузки. +- Генерация **асинхронная**: заказать отчёт за диапазон дат → отчёт строится (секунды) → + появляется в «Списке отчётов» со статусом «Обработан» → скачать `/admin/report/getfile?id=N`. + +Разведано 18.05.2026 (см. memory `reference_supplier_crm.md` §«Отчёты / выгрузки»). +Альтернатива «Выгрузка данных» (XLSX, 10 колонок с id и временем) — НЕ в self-service +dropdown, генерируется иначе; отвергнута как источник — «Запрос номеров» легче и доступен. + +## 3. Поток данных + +Каждые 30 минут (Laravel scheduler) **и** по кнопке «Сверить сейчас»: + +``` +1. Cache::lock('supplier:csv_reconcile', 600s) — overlap-защита (skip если занят). +2. INSERT supplier_csv_reconcile_log (status='running'). +3. SupplierPortalClient.requestNumbersReport(from, to) → report_id. + from..to = окно (вчера 00:00 .. сегодня 23:59 — 2 календарных дня). +4. SupplierPortalClient.waitReportReady(report_id) — polling статуса «Обработан». +5. SupplierPortalClient.downloadReport(report_id) → raw CSV. +6. SupplierCsvParser.parse(csv) → строки {project, tag, phone}. +7. Дедуп: для каждой CSV-строки ключ (phone, project). SELECT существующих + supplier_leads за окно → set ключей. Строки вне set → missing. +8. Для каждой missing: + - INSERT supplier_leads (vid=NULL, source='csv_recovery', + recovered_from_csv_at=now(), received_at=now(), raw_payload=строка). + - dispatch RouteSupplierLeadJob(lead_id). +9. drift_ratio = missing / total_csv_rows. +10. UPDATE supplier_csv_reconcile_log: total/matched/recovered/drift/status. +11. drift > 5% → CsvDriftAlertMail + плашка + колокольчик. +12. На любом исключении — status='failed', error_message, throw (cron повторит через 30 мин). +``` + +## 4. Компоненты + +### 4.1. `SupplierCsvParser` — переписать (rework) + +Текущая версия ([app/app/Services/Supplier/SupplierCsvParser.php](../../../app/app/Services/Supplier/SupplierCsvParser.php)) +написана под неверную догадку: 6 колонок `vid;project;tag;phone;phones;time`. + +Новая версия: + +- Заголовок `Name;Tag;Phone` — 3 колонки. +- BOM (UTF-8 `EF BB BF`) + CRLF→LF — сохранить. +- Streaming-generator — сохранить. +- Malformed-строки (< 3 колонок) — skip + `Log::warning` — сохранить. +- Возвращает `iterable` — без `vid`/`time`. + +### 4.2. `CsvReconcileJob` — переписать (rework) + +Текущая версия ([app/app/Jobs/Supplier/CsvReconcileJob.php](../../../app/app/Jobs/Supplier/CsvReconcileJob.php)) +строит дедуп на `vid` (`$csvByVid`, `array_diff_key`), вызывает синхронный +`downloadLeadsCsv` — оба невозможны на реальном CSV. + +Новая версия: + +- Async-флоу заказа отчёта (шаги 3–5 §3) вместо `downloadLeadsCsv`. +- Дедуп по `(phone, project)` вместо `vid` (§5). +- Окно — `WINDOW_DAYS = 2` календарных дня (вчера + сегодня). +- `Cache::lock` overlap-защита — сохранить. +- `supplier_csv_reconcile_log` запись — сохранить (status CHECK `running/ok/drift_alert/failed` + уже существует, не меняется). +- drift-порог `0.05` — сохранить. +- vid у создаваемых `supplier_leads` — `NULL` (см. §6). + +### 4.3. `SupplierPortalClient` — расширить + +Добавить 3 метода (auth/retry семантика — от приватного `request()`, как у существующих): + +- `requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int` + — POST-запрос генерации отчёта «Запрос номеров» за диапазон → возвращает `report_id`. +- `waitReportReady(int $reportId): void` + — polling статуса отчёта до «Обработан»; таймаут (напр. 60s) → `SupplierTransientException`. +- `downloadReport(int $reportId): string` + — GET `/admin/report/getfile?id=N` → raw CSV-body. + +Метод `downloadLeadsCsv` — удалить (единственный потребитель — старый `CsvReconcileJob`; +проверить grep при реализации). + +> **Discovery-оговорка:** точные имена endpoint'ов и поля payload отчёта «Запрос +> номеров» — placeholder (как было с `rt-*` endpoints в §4.4 spec'а 2026-05-10). +> Уточняются на реальном портале при реализации; при расхождении — fixup-commit. + +### 4.4. UI — страница «Интеграция с поставщиком» (админ-часть) + +Новый экран в админ-части портала: + +- **Здоровье канала:** дата/статус последней сверки, текущий `drift %`, индикатор + «webhook live / webhook down» (down — если последняя сверка дала drift > 5%). +- **Кнопка «Сверить сейчас»** → `POST` → dispatch `CsvReconcileJob` вручную (вне расписания). +- **История сверок** — таблица из `supplier_csv_reconcile_log` (последние N записей: + время, окно, total/matched/recovered, drift, статус). + +### 4.5. Расписание + +`CsvReconcileJob` — в Laravel scheduler `everyThirtyMinutes()`. +Ручной запуск кнопкой — всегда доступен независимо от расписания. + +## 5. Дедуп + +CSV «Запрос номеров» не содержит `vid` и времени → дедуп по `vid` невозможен. + +**Ключ дедупа: `(phone, project)`** за окно сверки. + +- `project` — поле `B_` из CSV-строки (= `raw_payload['project']` у webhook-лидов). +- Существующие `supplier_leads` за окно (по `received_at`) → set ключей `phone|project`. +- CSV-строка, чьего ключа нет в set → missing → recovery. + +**Известное ограничение:** если за окно поступило две *разных* заявки с одним телефоном +на один проект — CSV-канал подберёт только одну (вторую сочтёт уже принятой). Это +приемлемо, потому что: + +1. CSV-канал — резервный, его задача — не потерять лид, а не точный аудит. +2. Даже если бы CSV создал второй `supplier_lead` — `RouteSupplierLeadJob` через + `DuplicateDetector::findMaster` (phone + окно 24ч) пометил бы его дублем + **без списания баланса**. Деньги защищены на уровне routing, не дедупа CSV. + +## 6. Миграция: `supplier_leads.vid` → nullable + +Сейчас `supplier_leads.vid` — `bigint NOT NULL` с `idx_supplier_leads_vid_unique` +(UNIQUE-индекс). CSV «Запрос номеров» vid не содержит. + +**Миграция:** `ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL`. + +- Webhook-лиды — без изменений (настоящий `vid` от поставщика). +- CSV-recovered лиды — `vid = NULL`. +- UNIQUE-индекс `idx_supplier_leads_vid_unique` **остаётся**: в PostgreSQL несколько + строк с `vid = NULL` не нарушают UNIQUE (NULL ≠ NULL) — много CSV-лидов с NULL сосуществуют. +- `RouteSupplierLeadJob` пишет `Deal.source_crm_id = lead.vid` → для CSV-лида `NULL`. + `Deal.source_crm_id` уже nullable (webhook-only поле). Дедуп сделок — `DuplicateDetector` + по phone+окну, не по `source_crm_id` — не ломается. + +Запись в `db/CHANGELOG_schema.md` обязательна (правило §4.2). RLS на `supplier_leads` +не меняется (миграция трогает только nullability колонки). + +## 7. Drift-детект и уведомления + +`drift_ratio = missing_count / total_csv_rows`. + +- `drift ≤ 5%` → status `ok` (webhook здоров; recovery подобрал мелочь — норма). +- `drift > 5%` → status `drift_alert` → webhook систематически теряет лиды: + - `CsvDriftAlertMail` ([app/app/Mail/CsvDriftAlertMail.php](../../../app/app/Mail/CsvDriftAlertMail.php) — уже существует). + - Плашка в портале + колокольчик (in-app notification). + - **Письмо фактически отправляется только после настройки почты (Б-1)** — до этого + Mailable формируется, но доставка зависит от mail-конфига; плашка+колокольчик работают сразу. + +## 8. Error handling + +| Сбой | Поведение | +|---|---| +| Playwright-логин упал / сессия не обновилась | `SupplierAuthException` → `reconcile_log` status=`failed`, throw, cron повторит | +| Портал поставщика 5xx | `SupplierTransientException` → status=`failed`, throw | +| Отчёт не дошёл до «Обработан» за таймаут | `SupplierTransientException` → status=`failed` | +| Параллельный запуск (cron + кнопка) | `Cache::lock` занят → skip с `Log::info`, без записи в log | +| CSV-строка с непарсимым `project` | skip + `Log::warning`, остальные обрабатываются | +| `vid`-коллизия (CSV-лид совпал с webhook-vid) | невозможна — CSV-лиды имеют `vid=NULL` | + +`$tries = 1` у `CsvReconcileJob` — повтор обеспечивает cron каждые 30 мин, не retry-механизм. + +## 9. Тестирование + +- **`SupplierCsvParser`** (Pest unit): 3-колоночный заголовок, BOM, CRLF, malformed-строки skip, + пустой CSV, корректный generator-выход. +- **`CsvReconcileJob`** (Pest feature): дедуп phone+project (missing подобран, существующий + пропущен), drift расчёт + порог 5%, missing → `RouteSupplierLeadJob` dispatched, + overlap-lock skip, fail-path (status=`failed` на исключении), drift_alert → Mailable. +- **`SupplierPortalClient`** новые методы — HTTP-факт мокается (`Http::fake`), как у существующих. +- **Миграция vid** (Pest feature/db): `supplier_leads` принимает `vid=NULL`; несколько + NULL-строк сосуществуют под UNIQUE-индексом; webhook-путь с настоящим vid не сломан. +- **UI** (Vitest): страница «Интеграция с поставщиком» рендерит здоровье/историю; + кнопка «Сверить сейчас» вызывает endpoint. +- Регрессия: существующие `RouteSupplierLeadJobTest`, `SupplierWebhookTest`, + `CsvReconcileJobTest` (последний переписывается под новый дедуп). + +## 10. Что НЕ входит (YAGNI) + +- Параллельный CSV-поток как равноправный канал — нет, только recovery. +- Импорт исторических лидов — отдельный флоу (Sprint 4, `HistoricalImportService`), не трогаем. +- Региона/города из CSV — у поставщика гео-данных нет (см. открытый вопрос «Город»). +- Ретрай-механизм внутри job — повтор обеспечивает 30-мин cron. + +## 11. Журнал решений + +| # | Решение | Обоснование | +|---|---|---| +| 1 | Источник — отчёт «Запрос номеров» (CSV 3 колонки) | Лёгкий, в self-service dropdown; «Выгрузка данных» XLSX — не self-service | +| 2 | Частота авто-сверки — 30 минут | Баланс свежести подхвата и нагрузки на портал поставщика (подтв. заказчиком 18.05) | +| 3 | Окно — 2 календарных дня (вчера+сегодня) | CSV без времени; покрывает скользящие 24ч независимо от гранулярности диапазона портала | +| 4 | Дедуп по (phone, project) | CSV не содержит vid; деньги защищены DuplicateDetector в RouteJob | +| 5 | `vid` → nullable (миграция) | CSV не даёт vid; NULL-ы не конфликтуют под UNIQUE-индексом (PG semantics) | +| 6 | Письмо drift-alert — после Б-1 | Почта не настроена; плашка+колокольчик работают сразу |