R3 Часть F: CsvReconcile recovery+drift, терминальные пути, каскад-edges, вечерняя заморозка+reminder/final, T7, ручная-cost/manager, cost null, индекс, bulk-изоляция, причины удаления проекта. R3b Часть F: отчёты retry/cancel/destroy/signed-URL, импорт парсер/провенанс, онбординг/реквизиты edges + НОВЫЕ карточки G6 (API) и G7 (поддержка/impersonation). R2: I8 HMAC-путь, I9 phones[]. Всё по факту кода (file:line). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
🆕 Приёмка 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 (коммит
c8b54e98). Замечания оттуда (Татарстан=16, F-CORPUS, админ fail-open, капча-Null, F-CSV-wide, append-only, lpimp_ статус, tenants-RLS) другая сессия уже правит — здесь их НЕТ. Этот файл = находки, которых в первом отчёте не было (Раздел A) + предложения по расширению охвата теста (Раздел B). Всё по факту кода.🔄 Код — движущаяся цель. Зафиксировано: на момент письма другая сессия уже добавила
CsvFormulaGuard::neutralize()в DealExportController.php:129-134 (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-21-acceptance-owner-decisions.md.
РАЗДЕЛ A — НОВЫЕ находки (не в первом отчёте)
🔴 N-1 (CRITICAL, прод-безопасность) — Скрипт teardown R5 НЕ РАБОТАЕТ: тест-данные останутся на боевом
Перепроверено мной лично по коду и тексту ранбука. В R5 TD3 — единая транзакция BEGIN; … COMMIT;. В ней минимум две команды падают на несуществующих объектах, и любая ошибка откатывает ВСЮ транзакцию → ни одна тест-строка не удаляется, тест-тенанты + их данные остаются на проде (нарушение мандата §1 «вычищать ДО 21:00»).
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). А TD3 строка 93:DELETE FROM supplier_lead_costs WHERE tenant_id IN (<IDS>)→ ошибкаcolumn "tenant_id" does not exist→ ABORT транзакции (R5 TD3:93). Фикс:DELETE FROM supplier_lead_costs WHERE deal_id IN (SELECT id FROM deals WHERE tenant_id IN (<IDS>))— и поставить ДО удаленияdeals.- Таблица
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) | SupplierWebhookController.php:117 | нет | orphan (1/лид) |
lead_region_resolution_log |
нет (schema:2107) | RouteSupplierLeadJob.php:569 | нет | 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), не 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). «Непересекающееся окно» держится, только если прогоны ровно через 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). Если запустить на дату с активными боевыми проектами (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). Любой валидный slug принимается в любом порядке (won→new пройдёт 200). StatusRuToSlugMapper — это только для CSV-импорта (RU-метка→slug), API-путь его не зовёт.
Фикс: переформулировать ожидание на «любой валидный slug, воронка не форсится»; иначе исполнитель будет искать несуществующее поведение.
🟡 N-7 — R1: гейт реквизитов (422) и preflight баланса (409) не упомянуты в ранбуке R1
Создание проекта через портал блокируется, если у тенанта нет лёгких реквизитов → 422 requisites_required (ProjectController.php:131-133), и 409 при нехватке баланса под лимит (:140-156). SQL-засев тест-клиентов даёт тенанта без реквизитов → PR2 (16 проектов через портал) упрётся в 422.
Фикс: в R1 — предусловие: заполнить лёгкие реквизиты (subject_type+contact_name+contact_phone, +ИНН для ЮЛ/ИП) и выставить баланс ≥ Σ(лимиты)×тариф ДО создания/подъёма лимитов проектов.
🟡 N-8 — F4 (отчёт по марже) — битый SQL (две ошибки)
R5 F4:53: 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 (что важнее всего отдать правящей сессии)
- N-1 (CRITICAL) — починить скрипт teardown R5 TD3 (строки 93, 95) — иначе тест-данные останутся на боевом. Перепроверено по коду лично.
- N-2 / N-8 — orphan-таблицы инъекции + битый F4 (та же
supplier_lead_costs-ошибка) — манифест инъекции + правка SQL. - N-3 — запрет на удаление глобальных audit-chain таблиц + пояснение в R5 (чтобы F3 не покраснел).
- N-5 —
snapshot:rebuildопасен для tenant 2 — в R2 явно предпочестьbackfill. - N-4 — дайджест-дубль (кандидат в дефект) — проверить двойным прогоном.
- N-6 / N-7 — призрак-воронка статусов + гейт реквизитов в R1.
ТОП-5 расширения охвата (раздел B)
- CsvReconcileJob целиком (merge без 2-го списания + drift-алерты) — крупнейшая денежно-критичная слепая зона.
- Вечерняя заморозка + reminder/final письма (§20) — клиент-видимое, тонкое, не покрыто.
- Терминальные пути оркестратора (
lead==null— защита от прод-инцидента 25k) — регресс-кейсы. - Жизненный цикл отчётов (retry/cancel/destroy/signed-URL tamper) — реальная нетривиальная логика.
- Границы impersonation/G6-ключа — самый рисковый путь (машинный ключ к деньгам/админке).
Read-only разбор. Прод не тронут, код не правился. N-1/N-2/N-8/schema-факты перепроверены мной лично по коду. Остальное — суб-агенты с file:line, помечено где требуется живой прогон/мок. Коммит этого файла — по слову владельца.