Files
portal/docs/superpowers/specs/2026-06-21-acceptance-coverage-expansion-NEW.md
T
Дмитрий 219040326b docs(приёмка): Раздел B — расширение охвата ранбуков R2/R3/R3b
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>
2026-06-21 16:20:49 +03:00

28 KiB
Raw Blame History

🆕 Приёмка 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»).

  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). А 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.
  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) SupplierWebhookController.php:117 нет orphan (1/лид)
lead_region_resolution_log нет (schema:2107) RouteSupplierLeadJob.php:569 нет orphan (маскир. ПДн)
webhook_log tenant_id=NULL SupplierWebhookController.php:147 нет orphan (14/цикл)
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 (2448ч / 7296ч + 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, планы №1826)

Поведение 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-5snapshot: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, помечено где требуется живой прогон/мок. Коммит этого файла — по слову владельца.