# 🆕 Приёмка liderra.ru — НОВЫЕ находки (раунд 2) + расширение охвата теста

**Дата:** 21.06.2026. **Автор:** независимая сессия-ревьюер. **Кодовая фраза стены:** «роутер-наставник».
**Метод:** read-only, реальный код `app/` + `db/schema.sql` + ранбуки R0–R5; 4 суб-агента по доменам, ключевые факты перепроверены мной лично по коду. Falsify, без призраков — только то, что есть в коде (file:line).

> ## ⚠️ ЭТО НОВЫЙ ФАЙЛ — НЕ дубль первого отчёта
> Первый отчёт — [2026-06-21-acceptance-prep-review.md](2026-06-21-acceptance-prep-review.md) (коммит `c8b54e98`). Замечания оттуда (Татарстан=16, F-CORPUS, админ fail-open, капча-Null, F-CSV-wide, append-only, lpimp_ статус, tenants-RLS) **другая сессия уже правит** — здесь их НЕТ.
> Этот файл = **находки, которых в первом отчёте не было** (Раздел A) + **предложения по расширению охвата теста** (Раздел B). Всё по факту кода.
>
> 🔄 **Код — движущаяся цель.** Зафиксировано: на момент письма другая сессия уже добавила `CsvFormulaGuard::neutralize()` в [DealExportController.php:129-134](../../../app/app/Http/Controllers/Api/DealExportController.php#L129) (F-CSV сделок чинится) + создала новый `app/app/Support/CsvFormulaGuard.php` (untracked). Перед действием по находкам — перечитывать рабочее дерево, не HEAD.

---

## 🟢 СТАТУС ОБРАБОТКИ (обновлено 21.06.2026 — правящая сессия)

Раздел A разобран; код-факты перепроверены по `db/schema.sql`/`app/` ПЕРЕД правкой.

| Находка | Статус | Где закрыто |
|---|---|---|
| **N-1** teardown TD3 (supplier_lead_costs/reminders) | ✅ **закрыто** | R5 TD3 — `deal_id`-подзапрос + строка reminders убрана (`e0fb6e46`) |
| **N-2** orphan-таблицы инъекции | ✅ **закрыто (детерминированно)** | R5 — +pd_processing_log/tenant_operations_log по tenant_id; 4 orphan-таблицы (supplier_leads/lead_region_resolution_log/failed_webhook_jobs/webhook_log) чистятся по **TEST-диапазону vid** (маркер в R2 Предусловиях) — без ручного манифеста |
| **N-3** не удалять глобальные audit-chain | ✅ **закрыто** | R5 — нота про auth_log/saas_admin_audit_log |
| **N-4** дайджест-дубль при двойном прогоне | ✅ **закрыто (надёжно)** | TDD-фикс: пометка `digest_sent:<id>` в Redis ставится **ПОСЛЕ** возврата notify (mark-after-send) — повтор не дублирует И сбой/падение джоба до отправки не теряет дайджест (at-least-once). Тест «при падении отправки не помечает» — GREEN 6/6 |
| **N-5** snapshot:rebuild сносит день | ✅ **закрыто** | R2 — предупреждение + по умолчанию backfill с явной --date |
| **N-6** воронка статусов — призрак | ✅ **закрыто** | план №13 — нота «порядок не форсится, любой валидный slug» |
| **N-7** R1 гейт реквизитов/preflight | ✅ **закрыто** | R1 PR2 — предусловие 422/409 |
| **N-8** F4 маржа — битый SQL | ✅ **закрыто** | R5 F4 — cost_rub+JOIN; price_per_lead_kopecks (ревьюер ошибся в price_kopecks — сверено по схеме) |
| **Раздел B** ~25 код-путей без теста | ✅ **закрыто (карточки в ранбуках)** | R3 Часть F (D-RECONCILE/D-TERMINAL/D-DADATA/D-CASCADE3+/M-FREEZE-SWEEP/M-TIER7/T-MANUAL+/T-CARD-NULL/T-INDEX/T-BULK-ISO/T-DELPROJ+); R3b Часть F (RPT-LIFECYCLE/RPT-URL/IMP-PARSER/IMP-PROVENANCE/ONB-EDGE/REQ-EDGE/BELL-EDGE + **G6 API-KEY/API-DEALS, G7 SUPPORT/IMPERS**); R2 I8 HMAC/I9 phones[] |

Находки первого отчёта (`acceptance-prep-review.md`) и их статусы — в реестре
[2026-06-20-acceptance-findings-register.md](2026-06-20-acceptance-findings-register.md) и
выносе [2026-06-21-acceptance-owner-decisions.md](2026-06-21-acceptance-owner-decisions.md).

---

## РАЗДЕЛ A — НОВЫЕ находки (не в первом отчёте)

### 🔴 N-1 (CRITICAL, прод-безопасность) — Скрипт teardown R5 НЕ РАБОТАЕТ: тест-данные останутся на боевом

**Перепроверено мной лично по коду и тексту ранбука.** В R5 TD3 — единая транзакция `BEGIN; … COMMIT;`. В ней **минимум две команды падают на несуществующих объектах**, и любая ошибка откатывает ВСЮ транзакцию → **ни одна тест-строка не удаляется**, тест-тенанты + их данные остаются на проде (нарушение мандата §1 «вычищать ДО 21:00»).

1. **`supplier_lead_costs` не имеет `tenant_id`.** Столбцы: `id, deal_id, received_at, supplier_id, cost_rub, supplier_lead_id, supplier_invoice_id, created_at` ([db/schema.sql:2589-2599](../../../db/schema.sql#L2589)). А TD3 строка 93: `DELETE FROM supplier_lead_costs WHERE tenant_id IN (<IDS>)` → ошибка `column "tenant_id" does not exist` → ABORT транзакции ([R5 TD3:93](../runbooks/2026-06-18-acceptance-r5-finalize-teardown.md)).
   **Фикс:** `DELETE FROM supplier_lead_costs WHERE deal_id IN (SELECT id FROM deals WHERE tenant_id IN (<IDS>))` — и поставить ДО удаления `deals`.
2. **Таблица `reminders` ДРОПНУТА** (G3, миграция `drop_reminders_table`), а TD3 строка 95: `DELETE FROM reminders WHERE tenant_id IN (<IDS>)` → ошибка `relation "reminders" does not exist` → ABORT.
   **Фикс:** убрать строку 95 целиком.

**Эффект:** даже если исполнитель аккуратно подставит `<IDS>`, transaction упадёт на первой из этих строк (93 идёт раньше 95) → `ROLLBACK` → орфан тест-данных на боевом + (если TD1 тоже сорвётся) живые rt у поставщика. **Самая важная находка раунда.**

### 🔴 N-2 (прод-безопасность) — Teardown не достаёт 6 таблиц, куда пишет инъекция (orphan-данные, в т.ч. ПДн)

TD3 фильтрует строго `WHERE tenant_id IN (<IDS>)`. Но инъекция (R2/R3) пишет в **shared/не-tenant таблицы**, которые этот фильтр не достанет НИКОГДА:

| Таблица | tenant_id? | Кто пишет | В TD3? | Итог |
|---|---|---|---|---|
| `supplier_leads` | **нет** ([schema:2015](../../../db/schema.sql#L2015)) | [SupplierWebhookController.php:117](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L117) | нет | **orphan** (1/лид) |
| `lead_region_resolution_log` | **нет** ([schema:2107](../../../db/schema.sql#L2107)) | [RouteSupplierLeadJob.php:569](../../../app/app/Jobs/RouteSupplierLeadJob.php#L569) | нет | **orphan** (маскир. ПДн) |
| `webhook_log` | tenant_id=NULL | SupplierWebhookController.php:147 | нет | **orphan** (1–4/цикл) |
| `failed_webhook_jobs` | tenant_id=NULL | RouteSupplierLeadJob.php:646 | нет | **orphan** (при сбоях R4) |
| `pd_processing_log` | да (по тенанту) | PdAuditLogger (job:487) | **нет** | **orphan** (152-ФЗ ПДн) |
| `tenant_operations_log` | да (по тенанту) | OperationsLogger:39 | **нет** | **orphan** (операции проекта) |

**Фикс:** при R2 вести **манифест инъекции** (список `vid` + `supplier_project` id), и в TD3 добавить очистку первых четырёх по этому манифесту, а `pd_processing_log`/`tenant_operations_log` — по `tenant_id IN (<IDS>)` под тем же trigger-bypass.

### 🟠 N-3 — Глобальные audit-chain таблицы НЕЛЬЗЯ удалять (иначе F3 verify-chains покраснеет)

`auth_log` и `saas_admin_audit_log` — **глобальная** hash-цепочка (`partition: ''`, [AuditChainConfig.php](../../../app/app/Services/Audit/AuditChainConfig.php)), не per-tenant. Логины тест-клиентов пишут `auth_log` вперемешку с боевыми. Удаление их строк разорвёт глобальную цепочку → `audit:verify-chains` (F3) упадёт. Сейчас TD3 их (правильно) не трогает, но **в ранбуке это не объяснено** — наивный оператор может «почистить» и сломать F3.
**Фикс:** в R5 явно запретить удалять `auth_log`/`saas_admin_audit_log` + пояснить: per-tenant цепочки (4 таблицы) при удалении целого тест-тенанта рвутся безопасно (своя цепочка тенанта исчезает целиком, чужие целы) — поэтому F3 для tenant 2 остаётся зелёным.

### 🟠 N-4 (кандидат в реальный дефект) — Дайджест дублируется при двойном прогоне джоба

`SendNewLeadsDigestJob` выбирает сделки `received_at > now()-30min` **без персистентного маркера отправки** ([SendNewLeadsDigestJob.php:45](../../../app/app/Jobs/SendNewLeadsDigestJob.php#L45)). «Непересекающееся окно» держится, только если прогоны ровно через 30 мин. **А ранбук R3b сам велит исполнителю «запусти джоб вручную»** — ручной прогон + следующий крон (или retry) перекрывают окно → **одни и те же сделки уходят в дайджест дважды**. Карточка DIGEST-WINDOW ожидает «второй прогон ничего не шлёт» — это **не совпадает с кодом**.
**Действие:** проверить двумя прогонами подряд (ожидать 1 письмо; сейчас будет 2). Если дефект подтвердится на проде — кандидат на фикс (per-deal флаг / `digest_sent_at`), спросить владельца.

### 🟠 N-5 (прод-безопасность) — `snapshot:rebuild` стирает слепок ВСЕГО дня (включая tenant 2)

`snapshot:rebuild --date=<d>` делает DELETE+INSERT по слепку даты **без транзакции и без фильтра тенанта** ([SnapshotRebuildCommand.php](../../../app/app/Console/Commands/SnapshotRebuildCommand.php)). Если запустить на дату с активными боевыми проектами (lkomega) — снесёт их слепок дня посреди суток.
**Фикс:** в R2 I2 — использовать `snapshot:backfill` (идемпотентно, ON CONFLICT DO NOTHING); `rebuild` — только на дату без боевых проектов. Плюс грабля: `snapshot:backfill` без `--date` берёт `today` МСК **без сдвига 21:00** (сдвигает только роутер) → после 21:00 соберёшь не ту дату → лид `no_snapshot_skipped`. Дату передавать явно.

### 🟡 N-6 — План №13/№14: «воронка статусов» — призрак (в коде её нет)

План №13 §13-3 и ранбук T-STATUS описывают «запрещённые/обратные переходы воронки». В коде перехода **нет state-machine**: `update`/`transition` проверяют лишь существование slug в `lead_statuses` ([DealController.php:358-365](../../../app/app/Http/Controllers/Api/DealController.php#L358)). Любой валидный slug принимается в любом порядке (`won→new` пройдёт 200). `StatusRuToSlugMapper` — это только для CSV-импорта (RU-метка→slug), API-путь его не зовёт.
**Фикс:** переформулировать ожидание на «любой валидный slug, воронка не форсится»; иначе исполнитель будет искать несуществующее поведение.

### 🟡 N-7 — R1: гейт реквизитов (422) и preflight баланса (409) не упомянуты в ранбуке R1

Создание проекта через портал блокируется, если у тенанта нет лёгких реквизитов → 422 `requisites_required` ([ProjectController.php:131-133](../../../app/app/Http/Controllers/Api/ProjectController.php#L131)), и 409 при нехватке баланса под лимит ([:140-156](../../../app/app/Http/Controllers/Api/ProjectController.php#L140)). SQL-засев тест-клиентов даёт тенанта **без** реквизитов → PR2 (16 проектов через портал) упрётся в 422.
**Фикс:** в R1 — предусловие: заполнить лёгкие реквизиты (subject_type+contact_name+contact_phone, +ИНН для ЮЛ/ИП) и выставить баланс ≥ Σ(лимиты)×тариф ДО создания/подъёма лимитов проектов.

### 🟡 N-8 — F4 (отчёт по марже) — битый SQL (две ошибки)

[R5 F4:53](../runbooks/2026-06-18-acceptance-r5-finalize-teardown.md): `sum(cost_kopecks) FROM supplier_lead_costs WHERE tenant_id=t.id` — (а) нет `tenant_id`, (б) столбец называется `cost_rub` (DECIMAL), не `cost_kopecks`. Запрос упадёт. Строка 52 `lead_charges`: сверить имя столбца суммы (в схеме — `price_kopecks`? проверить, т.к. F4 берёт `price_kopecks` — это уже верно по `lead_charges`).
**Фикс:** `(SELECT COALESCE(sum(cost_rub),0) FROM supplier_lead_costs c JOIN deals d ON d.id=c.deal_id AND d.received_at=c.received_at WHERE d.tenant_id=t.id)`.

---

## РАЗДЕЛ B — Расширение охвата (реальные код-пути БЕЗ шага в тестах)

Только поведения, существующие в коде; для каждого — file:line и предлагаемый кейс. Приоритет — по риску (деньги/изоляция/прод-безопасность выше UI).

### B.1 Движок: деньги + распределение (R3, планы №1–12)
| Поведение | file:line | Покрыто? | Предлагаемый кейс |
|---|---|---|---|
| **CsvReconcile MERGE без 2-го списания** (vid ловит csv-recovered сделку → 1 сделка, регион улучшается, `received_at` НЕ меняется) | RouteSupplierLeadJob.php:351-402 | НЕТ (D-MERGE отложен, план №5 §5-3 явно «глазами не показать») | CsvReconcileJob создаёт csv-сделку → webhook тем же phone+project+tenant с реальным vid → 1 `deals`, `source_crm_id` проставлен, `count(lead_charges)=1`, `received_at` не изменился |
| **CsvReconcile drift-алерты** webhook-loss >5% / business-drift >20% / recovery `source=csv_recovery` | CsvReconcileJob.php:148-159,175-202,264-323 | НЕТ | стейдж CSV с пропусками → `supplier_csv_reconcile_log.status='drift_alert'` + письмо; per-(дата,тенант) shortfall → `TenantBusinessDriftAlertMail` |
| **Терминальные пути оркестратора:** `lead==null` (защита от 25k-шторма), fast-fail terminal, частичный успех (deals_created_count), all-fail → RuntimeException→`failed_webhook_jobs` | RouteSupplierLeadJob.php:102-108,126-146,172-202,646-655 | НЕТ | по кейсу на каждый: удалённый лид → чистый return без `failed_webhook_jobs`; один получатель падает, другой получает; все падают → retry→failed |
| **Вечерняя заморозка @18:00** (смотрит на ЗАВТРА; разморозка сохраняет ручные паузы `paused_at<frozenAt`; идемпотентность) | BalancePreflightSweepJob.php:62,95-116 | НЕТ (план №9 — только мгновенная пауза §19.5) | прогнать `BalancePreflightSweepJob`: баланс хватает на сегодня, не на завтра → freeze+письмо; пополнить→sweep→разморозка не трогает ручную паузу |
| **Письма reminder/final** (24–48ч / 72–96ч + throttle marker) | BalanceFrozenReminderJob.php:39-45,97-116 | НЕТ | бэкдейт `frozen_by_balance_at` на 30ч→reminder; повтор окна→тихо; 80ч→final |
| **Граница ступени T7 (∞)** | PricingTierResolver | НЕТ (план №8 только T1→T2) | delivered_in_month=6000 → tier_no=7, 250₽, дальше не падает |
| **Каскад фаза-3: внутренний `subject_code`=подмена, `city`=реальный** | RouteSupplierLeadJob.php:433-435,452 | частично (план №3 C-3 проверяет city+флаг, не subject_code) | добавить в C-3 проверку `subject_code`=подставленный (1-й из snapshot.regions) |
| **DaData-каскад edges:** бюджет исчерпан→Россвязь; qc=2/7→tag; идемпотентность ре-резолва | LeadRegionResolver.php:47-50,79,101-104 | НЕТ | мок-кейсы (требуют флаг dadata.enabled + мок) |

### B.2 Сделки / экспорт / изоляция (R3, планы №13–16, 25)
| Поведение | file:line | Покрыто? | Кейс |
|---|---|---|---|
| **bulk write-изоляция + лимит ≤1000** (чужие id отсеяны; 1001→422; no-op фильтр; ActivityLog source='bulk') | DealBulkActionController.php:52,73-81,99-114,139,203 | НЕТ (ISO-кейсы только read/single) | bulk [свой,чужой] → тронут только свой; 1001→422; после bulk destroy — N строк activity_log event=deal.deleted |
| **Ручное создание: SupplierLeadCost создаётся** (себестоимость, не баланс) + FK-гард менеджера + firstOrCreate проекта | DealController.php:484-539 | НЕТ (план №14 только «баланс не тронут») | ручная сделка на проекте с поставщиком → 0 баланс, +1 `supplier_lead_costs`; чужой manager_id→422 |
| **Карточка cost_kopecks=null при charge_source≠'rub'** | DealController.php:311 | частично | сделка csv_recovery/prepaid → cost_kopecks=null (T-CARD ошибочно ждёт «цена корректна») |
| **Индекс: count_only, only_deleted (Корзина), keyset cursor + битый cursor→422** | DealController.php:72-92,109-141 | НЕТ | Корзина показывает только удалённые; cursor=мусор→422 |
| **Удаление проекта: 422 has_deals vs snapshot_locked (разные сообщения); bulk-delete skip-reason** | ProjectService.php:184-191,319-347 | частично (№15 правит формулировку) | проект с поставщиком+сделками → 422 со snapshot-сообщением (не has_deals) |
| **Impersonation: авто-истечение 60м → 401 + endSession + письмо; lpimp_ границы (403 admin / 401 billing-api-keys / читает /api/billing/charges)** | ImpersonationContext.php:35-48; EnsureSaasAdmin.php:39-43 | НЕТ (план №25 prod-only) | feature-тест границ ключа (см. также N в первом отчёте про статус 401/403) |

### B.3 Онбординг / реквизиты / отчёты / импорт / G6 / G7 (R3b, планы №18–26)
| Поведение | file:line | Покрыто? | Кейс |
|---|---|---|---|
| **Отчёты: retry (3 попытки/окно 7д), cancel только PENDING, destroy только terminal+удаление файла+аудит** | ReportJobController.php:206-231,289-294,330-350 | НЕТ (план №22 — только happy-path) | fail→retry×3→422; retry старше 7д→422; cancel processing→отказ; destroy done→файл удалён+аудит |
| **Отчёты: signed-URL подмена→403, истёкший→410, файл 30д vs URL 24ч** | ReportJobController.php:360,380-384,413 | частично | подмена подписи→403; после expiry — done но downloadUrl=null+410 |
| **Отчёты: квота TOCTOU (count без лока)** | ReportJobController.php:143-160 | НЕТ | 2 параллельных store на границе квоты → возможно 4 активных |
| **Импорт: историческая нулевая транзакция** (type=historical_import, amount_rub=0; пропуск при 0 строк) | HistoricalImportService.php:281-295 | НЕТ | импорт N>0 → 1 строка balance_transactions amount=0; импорт 0 валидных → нет строки (исправить ошибочный шаг №23) |
| **Импорт: парсер по-правилам** (<9 колонок, телефон `7\d{10}`, дата `Y/m/d H:i:s`, пустой статус/проект), in-file дубли, dry_run, mimes/10MB | CsvLeadsParser.php:47-104; HistoricalImportService.php:148-169; StoreImportRequest:24-29 | частично (IMP-VALID — одна общая строка) | по кейсу на правило; 2 строки с одним source_crm_id→1 сделка; dry_run→0 записей |
| **G6 API edges:** limit-clamp 1..500; битый since/cursor→422; revoked vs expired vs bad-hash→401; нет scope→403; last_used_at/ip пишутся | V1/DealsController.php:25-46; ApiKeyAuth.php:34-57 | частично (план №26 перечисляет, не шагает границы) | limit=1000→500; since=мусор→422; revoke ключ→401; запрос→last_used_at заполнен |
| **Онбординг: старый код отвергается после resend; confirm `not_found`; `_dev_plain_code` скрыт на проде** | RegistrationService.php:83-85,210-214,231 | частично | после resend старый код→422; confirm без pending→`not_found`; на проде ответ без `_dev_plain_code` |
| **Реквизиты: `requisites_completed_at` сбрасывается при очистке bank_account; lookup found:false; нормализация телефона** | RequisitesService.php:18-25; TenantRequisitesController.php:44-45 | НЕТ | upsert bank_account→чип; очистить→чип снят; ИНН не найден→{found:false} |
| **Колокольчик: limit 1..100→422; mark-read идемпотентно; чужой id→404; дайджест исключает is_test/soft-deleted** | InAppNotificationController.php:33-167; SendNewLeadsDigestJob.php:46-47 | частично | limit=101→422; mark-read дважды→read_at не меняется; чужой→404 |

### B.4 Инъекция / нагрузка (R2, R4)
| Поведение | file:line | Покрыто? | Кейс |
|---|---|---|---|
| **HMAC-путь авторизации вебхука** (X-Webhook-Signature вместо секрета в URL) | SupplierWebhookController.php:53-62 | НЕТ (R2 только URL-секрет) | POST с HMAC-заголовком без секрета в URL → 202 |
| **Явные reject-кейсы:** плохой секрет→404 `rejected_secret`; не-allowlist IP на проде→404 `rejected_ip`; rate 600/мин→429; time за ±24ч | SupplierWebhookController.php:62-101 | частично | инъекция каждого негатива (fail-closed подтвердить) |
| **Мульти-телефон `phones[]`** (копируется в deals.phones) | RouteSupplierLeadJob.php:425-428 | НЕТ | вебхук с phones[] → сделка с массивом |
| **Нагрузка: retry×3 на лид; lock сессии 90-95с на единственном воркере** | RouteSupplierLeadJob.php:65-69; RefreshSupplierSessionJob.php:50-65 | НЕТ | под R4 ramp фиксировать рост `failed_webhook_jobs` и стопы очереди ~90с при refresh |

---

## Приоритеты раздела A (что важнее всего отдать правящей сессии)

1. **N-1 (CRITICAL)** — починить скрипт teardown R5 TD3 (строки 93, 95) — иначе тест-данные останутся на боевом. Перепроверено по коду лично.
2. **N-2 / N-8** — orphan-таблицы инъекции + битый F4 (та же `supplier_lead_costs`-ошибка) — манифест инъекции + правка SQL.
3. **N-3** — запрет на удаление глобальных audit-chain таблиц + пояснение в R5 (чтобы F3 не покраснел).
4. **N-5** — `snapshot:rebuild` опасен для tenant 2 — в R2 явно предпочесть `backfill`.
5. **N-4** — дайджест-дубль (кандидат в дефект) — проверить двойным прогоном.
6. **N-6 / N-7** — призрак-воронка статусов + гейт реквизитов в R1.

## ТОП-5 расширения охвата (раздел B)

1. **CsvReconcileJob** целиком (merge без 2-го списания + drift-алерты) — крупнейшая денежно-критичная слепая зона.
2. **Вечерняя заморозка + reminder/final письма** (§20) — клиент-видимое, тонкое, не покрыто.
3. **Терминальные пути оркестратора** (`lead==null` — защита от прод-инцидента 25k) — регресс-кейсы.
4. **Жизненный цикл отчётов** (retry/cancel/destroy/signed-URL tamper) — реальная нетривиальная логика.
5. **Границы impersonation/G6-ключа** — самый рисковый путь (машинный ключ к деньгам/админке).

---

*Read-only разбор. Прод не тронут, код не правился. N-1/N-2/N-8/schema-факты перепроверены мной лично по коду. Остальное — суб-агенты с file:line, помечено где требуется живой прогон/мок. Коммит этого файла — по слову владельца.*
