# R2 — Инъекционный стенд (приёмочный тест liderra.ru)

**Owner:** исполнитель приёмочного теста | **Когда:** во время прогона R3/R4 (по требованию)
**Зависит от:** R0 (прочитан), R1 (тест-клиенты и проекты созданы). База логики: `prod-logic-map.md` §4, §5, §18, §19.

## Назначение

Как влить **контролируемый лид «как будто от поставщика»** на боевой liderra.ru, чтобы прогонять раздачу/деньги/сделки **без живой поставки** (живую не гоняем — реальные деньги). Это инструмент для всех тестов R3/R4. Один лид через вебхук → движок раздаёт его до 3 клиентам → сделки + списания (см. §19).

## Предусловия

- [ ] R1 выполнен: есть тест-клиенты `TEST-*` с активными проектами. В online-режиме создание проекта уже создало `supplier_projects` + pivot `project_supplier_links` (см. §3) — для B-канала это нужно.
- [ ] Доступ `ssh liderra-prod`; для записи слепка/проверок RLS-таблиц — `sudo -u postgres psql`.
- [ ] **Вебхук-секрет** (≥32) — у владельца (`system_settings.supplier_webhook_secret`). В чат/логи НЕ писать.
- [ ] ⚠️ **IP-allowlist (обязательно).** На проде пустой `system_settings.supplier_ip_allowlist` = **fail-closed** (вебхук отвергает). POST инъекции должен идти с **разрешённого IP**. Варианты:
  - (рекомендую) инъекция **с самого прод-сервера**: `ssh liderra-prod 'curl ... http://127.0.0.1/...'` — но проверь, что 127.0.0.1/локальный IP в allowlist; ИЛИ
  - владелец **временно добавляет тест-IP** в `supplier_ip_allowlist` (админка / SQL), после теста убирает.
- [ ] Знать **активную дату слепка**: до 21:00 МСК — сегодня, после — завтра (`prod-logic-map §4`).
- [ ] 🏷 **Маркер инъекции для teardown (N-2, обязательно).** ВСЕ тест-инъекции используют `vid` из **зарезервированного TEST-диапазона `9000000000000 .. 9999999999999`** (13 цифр, заведомо выше реальных vid поставщика). Это **единственный детерминированный признак** на таблицах `supplier_leads` / `webhook_log` / `failed_webhook_jobs` — у них **нет `tenant_id`**, и по маркеру `TEST-` их не найти; по этому диапазону R5 их вычистит (TD3-orphan). Никогда не инъектируй с мелким/обычным `vid` — иначе orphan-данные (в т.ч. маскированные ПДн) останутся на боевом.

---

## Процедура (карточки уровня C)

### Шаг I0 — Предпроверка стенда
**§ref:** §5.1 (webhook). **Что:** убедиться, что секрет валиден, IP разрешён, активная дата понятна — до инъекции.

**Действие:**
```
# секрет задан и не плейсхолдер (длину видим, значение НЕ печатаем):
ssh liderra-prod 'psql -h 127.0.0.1 -U crm_app_user -d liderra -tA -c "SELECT length(value), (value='\''__SET_ON_DEPLOY__'\'') FROM system_settings WHERE key='\''supplier_webhook_secret'\''"'
# allowlist:
ssh liderra-prod 'psql -h 127.0.0.1 -U crm_app_user -d liderra -tA -c "SELECT value FROM system_settings WHERE key='\''supplier_ip_allowlist'\''"'
# активная дата (МСК):
ssh liderra-prod 'TZ=Europe/Moscow date "+%Y-%m-%d %H:%M"'
```
**Ожидаем:** length ≥ 32 и не `__SET_ON_DEPLOY__`; allowlist содержит твой IP инъекции (или пуст и ты будешь инъектировать с разрешённого источника). Если время < 21:00 → активная дата = сегодня, иначе завтра.
**Если не так:** секрет-плейсхолдер/короткий → у владельца взять/задать секрет. IP не в allowlist → добавить (владелец) или инъектировать с прод-сервера. **Без этого вебхук вернёт 404.**
**Покрывает:** §5.1 secret+IP gate.

### Шаг I1 — Проверить связку источника (B-канал) ИЛИ DIRECT
**§ref:** §19.4 (queryCandidates). **Что:** лид сматчится с проектом только если есть pivot (для `B[123]_`) или совпадает signal_type+identifier (для DIRECT).

**Действие (B-канал, напр. сайт `test-okna.ru`):**
```
ssh liderra-prod 'sudo -u postgres psql -d liderra -tA -c "SELECT sp.id, sp.platform, sp.unique_key, count(psl.project_id) FROM supplier_projects sp LEFT JOIN project_supplier_links psl ON psl.supplier_project_id=sp.id WHERE sp.unique_key='\''test-okna.ru'\'' GROUP BY sp.id"'
```
**Ожидаем:** есть строки supplier_projects (B1/B2/B3) для `test-okna.ru`, и pivot к тест-проектам непустой. (Создано автоматически при заведении проекта в online-режиме — R1.)
**Если не так:** проект не синканулся (R1) → проверь sync (`SyncSupplierProjectJob`) или заведи источник заново. **DIRECT** не требует pivot — пропусти эту карточку, в шаге I4 `project` будет без `B`-префикса.
**Покрывает:** §3 (заказ/pivot), §19.4 (матч источника).

### Шаг I2 — Построить слепок за активную дату
**§ref:** §4 (слепок), `SnapshotBackfillCommand`. **Что:** лид раздаётся ТОЛЬКО по слепку активной даты; без строки слепка проект пропускается (§19.4 «no_snapshot_skipped»).

**Действие:**
```
# idempotent (добавит отсутствующие; существующие не тронет) — ВСЕГДА с явной --date:
ssh liderra-prod 'cd /var/www/liderra/app && php artisan snapshot:backfill --date=<АКТИВНАЯ_ДАТА>'
# ⛔ N-5: snapshot:rebuild делает DELETE+INSERT по ВСЕМУ слепку даты БЕЗ фильтра
#    тенанта и БЕЗ транзакции → на дату с боевыми проектами (lkomega) снесёт их
#    слепок дня. Использовать ТОЛЬКО на дату без боевых проектов. По умолчанию —
#    backfill (идемпотентно). НЕ запускать rebuild на «сегодня» вслепую:
# php artisan snapshot:rebuild --date=<ДАТА_БЕЗ_БОЕВЫХ_ПРОЕКТОВ>
# проверка, что проекты теста попали в слепок:
ssh liderra-prod 'sudo -u postgres psql -d liderra -tA -c "SELECT project_id, tenant_id, daily_limit, regions FROM project_routing_snapshots WHERE snapshot_date='\''<АКТИВНАЯ_ДАТА>'\'' AND tenant_id IN (<TEST_TENANT_IDS>)"'
```
**Ожидаем:** строки слепка для всех тест-проектов активных на эту дату (с верным daily_limit и regions, коды 1..89). Проект, у которого день недели не входит в `delivery_days_mask`, в слепок НЕ попадёт — это корректно.
**Если не так:** проект не активен / preflight_blocked / тенант frozen / день вне маски → backfill его пропустит (см. WHERE в команде). Проверь состояние проекта/тенанта.
**Покрывает:** §4 (слепок-инвариант), §19.4 (лимит из слепка).

### Шаг I3 — Подобрать телефон под нужный регион
**§ref:** §5 (резолв региона), §18.3 (коды). **Что:** регион сделки определяется резолвером **по телефону** (DaData → Россвязь → tag). Коды субъектов — **порядковые 1..89** (`RussianRegions`): **Москва = 82**, СПб = 83 (НЕ ГИБДД!).

**Действие:**
- Для гео-нечувствительных тестов (шеринг/лимит/деньги) — регион не важен, бери любой валидный `7XXXXXXXXXX`.
- Для гео-тестов (каскад S2) — бери телефон с префиксом нужного региона, влей, и **СВЕРЬ** результат: `deals.subject_code` / `deals.city` == ожидаемый. Если резолвер дал не тот регион — поменяй телефон.
- Запасной детерминированный путь: если DaData выключен/бюджет исчерпан — резолвер уходит на `tag` (поле `tag` в payload). Тогда регион можно задать тегом (см. `RegionTagResolver`). На проде DaData включён → основной путь = телефон + сверка.
**Ожидаем:** знаешь, какой телефон даёт какой `subject_code` (проверяется по факту в I6).
**Если не так:** регион «уплыл» — это нормально для непредсказуемого DaData; для гео-теста подбирай телефон итеративно по факту сверки.
**Покрывает:** §5/§18.3 (резолв + коды).

### Шаг I4 — POST вебхук (B-канал)
**§ref:** §5.1, `SupplierWebhookController`. **Что:** отправить лид; создаётся `SupplierLead` + dispatch `RouteSupplierLeadJob`.

**Действие (с разрешённого IP; `time` — unix В ПРЕДЕЛАХ ±24ч от now, иначе крах партиции):**
```
ssh liderra-prod 'curl -s -X POST "https://liderra.ru/api/webhook/supplier/<СЕКРЕТ>" -H "Content-Type: application/json" -d "{\"vid\":9000000000001,\"project\":\"B2_test-okna.ru\",\"phone\":\"79161234567\",\"time\":<UNIX_NOW>}"'
```
**⚠️ `vid` — из TEST-диапазона `9000000000000..9999999999999`** (см. Предусловия, маркер teardown N-2), уникальный на инъекцию.
**Ожидаем:** HTTP 202 `{"status":"accepted","supplier_lead_id":N}`. Повтор того же `vid` → 200 `{"status":"already_processed"}` (идемпотентность — это норма).
**Если не так:** 404 → секрет/IP (см. I0). 422 → поле не прошло валидацию (phone `^7\d{10}$`, time в окне ±24ч, vid≥1). 429 → rate-limit 600/мин.
**Покрывает:** §5.1 приём + идемпотентность по vid.

### Шаг I5 — POST вебхук (DIRECT)
**§ref:** §5, §19.2. **Что:** проект без `B`-префикса → платформа DIRECT, матч по signal_type+identifier (без pivot).

**Действие:** как I4, но `"project":"test-okna.ru"` (без `B2_`). Для звонка — `"project":"79990001122"`; для смс — имя отправителя.
**Ожидаем:** 202 accepted; в сделке маршрут DIRECT (matched по identifier).
**Если не так:** нет проекта с таким signal_type+identifier в слепке → лид примут, но раздача 0 (никому). Проверь, что DIRECT-источник заведён и в слепке.
**Покрывает:** §19.2 (DIRECT-парсинг и матч).

### Шаг I6 — Проверить, что лид принят и обработан
**§ref:** §19.1. **Что:** воркер (один!) обработал лид и создал сделки.

**Действие:**
```
ssh liderra-prod 'sudo -u postgres psql -d liderra -tA -c "SELECT id, processed_at, deals_created_count, error FROM supplier_leads WHERE vid=<vid>"'
# сделки, созданные этим лидом:
ssh liderra-prod 'sudo -u postgres psql -d liderra -tA -c "SELECT d.id, d.tenant_id, d.subject_code, d.city, d.region_substituted FROM deals d JOIN supplier_lead_deliveries sld ON sld.deal_id=d.id WHERE sld.supplier_lead_id=<lead_id>"'
```
**Ожидаем:** `processed_at` заполнен; `deals_created_count` = числу получателей (≤3); по одной строке deals на получателя; `subject_code`/`city` = резолвнутому региону.
**Если не так:** `processed_at` пуст спустя минуту → воркер встал/очередь забита (один воркер!) — проверь `liderra-queue.service`. `error` непуст → смотри текст (terminal/transient). `deals_created_count=0` при ожидании раздачи → нет слепка/лимит/баланс/пауза (см. §19.4 логи `supplier_lead.*`).
**Покрывает:** §19.1 (обработка), §19.4 (создание сделок).

### Шаг I7 — Вливание ПАЧКИ (для шеринга S1 / лимита S3 / слоя D)
**§ref:** §19, R4. **Что:** влить N лидов подряд (разные `vid`) на один источник, чтобы проверить инварианты раздачи или замерить пропускную.

**Действие:** цикл из N POST'ов (I4) с инкрементом `vid` **в пределах TEST-диапазона** (`9000000000001, ...002, ...`) и одним и тем же `project`; для шеринга — один источник, много лидов; **сверять инварианты** (≤3 получателя на лид, лимит не превышен), НЕ точный состав (жребий в проде случайный).
**Ожидаем:** каждый лид → ≤3 разных тенанта; суммарно `delivered_today` каждого проекта ≤ его лимит; за ~15 лидов на 7 шерящих обслужены все.
**Если не так:** лид ушёл >3 / лимит превышен / `supplier_lead_deliveries` дубль на (lead,tenant) → **СТОП**, критдефект раздачи, зафиксируй и доложи.
**Покрывает:** §19 (CAP, лимит под локом, одна поставка клиенту = раз).

### Шаг T1 — TOTP-код для 2FA (для пункта 1 в R3)
**§ref:** prod-logic-map §1 (2FA). **Что:** посчитать 6-значный код из секрета, который портал показал при включении 2FA.

**Действие (на машине исполнителя, секрет base32 от портала):**
```
oathtool --totp -b "<СЕКРЕТ_BASE32>"
# или python: python -c "import pyotp,sys; print(pyotp.TOTP(sys.argv[1]).now())" "<СЕКРЕТ_BASE32>"
```
**Ожидаем:** 6 цифр, действительны ~30с (±1 окно). Вводишь в портал.
**Если не так:** код не принят → проверь часы машины (TOTP завязан на время); используй ±1 окно; recovery-код как запасной.
**Покрывает:** §1 (2FA TOTP).

### Шаг I8 — HMAC-путь авторизации (вместо секрета в URL) [Раздел B]
**§ref:** §5.1 (SupplierWebhookController). **Что:** вебхук принимает ИЛИ секрет в URL, ИЛИ HMAC-подпись тела в заголовке `X-Webhook-Signature` ([SupplierWebhookController.php:53-62](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L53)) — второй путь R2 раньше не гонял.
**Действие:** POST на `/api/webhook/supplier/` **без секрета в пути**, с заголовком `X-Webhook-Signature: <hmac_sha256(тело, секрет)>` (секрет у владельца; считать `openssl dgst -sha256 -hmac`). Тело — как I4, `vid` из TEST-диапазона.
**Ожидаем:** 202 accepted (как I4). Неверная подпись → 404 (`rejected_secret`).
**Покрывает:** §5.1 HMAC-аутентификация (timing-safe `hash_equals`).

### Шаг I9 — Мульти-телефон `phones[]` [Раздел B]
**§ref:** §19.4 (копирование phones в сделку). **Что:** payload с массивом `phones[]` (каждый `^7\d{10}$`) → попадают в `deals.phones` ([RouteSupplierLeadJob.php:425-428](../../../app/app/Jobs/RouteSupplierLeadJob.php#L425)).
**Действие:** как I4, но добавь `"phones":["79161234567","79169998877"]`.
**Ожидаем:** сделка создана; `deals.phones` содержит оба номера; основной `phone` — главный.
**Сверка:** `SELECT phone, phones FROM deals d JOIN supplier_lead_deliveries sld ON sld.deal_id=d.id WHERE sld.supplier_lead_id=<L>`.
**Покрывает:** §19.4 мульти-телефон.

---

## Verification (стенд готов)
- [ ] I0 зелёный: секрет валиден, IP разрешён, активная дата ясна.
- [ ] Пробный лид (I4) → 202; I6 показал `processed_at` + ожидаемые сделки.
- [ ] Повтор того же vid → 200 already_processed (идемпотентность жива).

## Troubleshooting
| Симптом | Причина | Фикс |
|---|---|---|
| 404 на вебхук | секрет/IP не прошли | I0: секрет ≥32 не плейсхолдер; IP в allowlist (или инъекция с прод-сервера) |
| 422 | валидация | phone `^7\d{10}$`; time в ±24ч; vid≥1; project ≤255 |
| 429 | rate-limit 600/мин/IP | подожди минуту |
| `processed_at` пуст >1мин | очередь/воркер (один!) | проверь `liderra-queue.service`, глубину очереди |
| deals_created_count=0 | нет слепка / лимит / баланс=0 / пауза | I2 слепок; проверь баланс/лимит/is_active проекта (§19.4) |
| регион не тот | DaData резолв | подбери телефон по факту (I3); либо tag-fallback |

## Rollback (быстрый откат инъекции; полный teardown — R5)
Инъекционные сделки/лиды — это записи тест-тенантов; удаляются в R5 по маркеру `TEST-`. Срочно убрать один лид:
```
# НЕ удалять lead_charges вручную (FK/партиции); полный teardown — через R5-скрипт по тенанту.
ssh liderra-prod 'sudo -u postgres psql -d liderra -tA -c "SELECT id FROM deals d JOIN supplier_lead_deliveries sld ON sld.deal_id=d.id WHERE sld.supplier_lead_id=<lead_id>"'
```
⚠️ Деньги/сделки связаны FK с партициями — точечное удаление рискованно. Откат — только через парный teardown-скрипт R5 (по тест-тенанту целиком). **Orphan-таблицы без `tenant_id`** (`supplier_leads`, `lead_region_resolution_log`, `webhook_log`, `failed_webhook_jobs`) R5 чистит **детерминированно по TEST-диапазону `vid`** — поэтому маркер vid (Предусловия) обязателен.

## Escalation
| Ситуация | Кому | Как |
|---|---|---|
| Критдефект раздачи/денег (I7) | владелец | СТОП, зафиксировать «ожидали/получили», не чинить |
| Нужен тест-IP в allowlist / секрет | владелец | админка/SQL, временно, убрать после |

## Матрица покрытия R2
| Инвариант (логика) | Карточка |
|---|---|
| §5.1 secret+IP gate, rate-limit | I0, I4 |
| §3/§19.4 матч источника (pivot/DIRECT) | I1, I5 |
| §4 слепок активной даты | I2 |
| §5/§18.3 резолв региона + коды 1..89 (Москва=82) | I3, I6 |
| §5.1 приём + идемпотентность vid | I4, I6, Verification |
| §19.2 DIRECT-парсинг | I5 |
| §19.1/§19.4 обработка, создание сделок | I6 |
| §19 CAP≤3, лимит под локом, одна поставка=раз | I7 |
| §5.1 HMAC-аутентификация (Раздел B) | I8 |
| §19.4 мульти-телефон phones[] (Раздел B) | I9 |
| §1 2FA TOTP | T1 |

**Чек-лист «не пропущено»:** ✅ secret/IP/дата · ✅ слепок · ✅ B-канал и DIRECT · ✅ контроль региона по факту · ✅ проверка обработки · ✅ пачка для инвариантов · ✅ TOTP. **Дальше → R3 (прогон пунктов).**
