# Резервный 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 | Почта не настроена; плашка+колокольчик работают сразу |
