docs(supplier): дизайн резервного CSV-канала (Путь 2) — spec

Резервный CSV-канал импорта лидов от crm.bp-gr.ru: страховка на случай
обрыва webhook. Сверка отчёта поставщика «Запрос номеров» (CSV 3 колонки
Name;Tag;Phone) каждые 30 мин + кнопка вручную; дедуп по phone+project;
recovery пропущенных лидов; drift-детект падения webhook.

Дизайн утверждён заказчиком. Ключевые решения: vid → nullable (CSV не
даёт vid), окно 2 кал. дня, rework SupplierCsvParser/CsvReconcileJob под
реальный async-флоу заказа отчёта.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-18 16:47:58 +03:00
parent 21307a86fe
commit bfe5e3e10c
@@ -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<N>_<rest>` (как в 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<array{project: string, tag: string, phone: string}>` — без `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<N>_<rest>` из 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 | Почта не настроена; плашка+колокольчик работают сразу |