LedgerService::resolveSupplierId returns suppliers.code='direct' row for
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
non-junk project strings as DIRECT (instead of dumping them into
unparseable_count) — this allows CSV recovery to also create DIRECT
supplier_leads, mirroring the webhook path.
CsvReconcileJobTest junk-rows fixtures updated: previously used callback
phone-number-as-project (79135551234) and URL-like strings as 'junk', but
those are now valid DIRECT identifiers. Replaced with truly junk strings
matching only outside-whitelist symbols (e.g. '???', '!@#').
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CsvReconcileJob каждый час стабильно ставил drift_alert ~40-50% (10 запусков
подряд на проде → admin-блок «Здоровье резервного канала» показывал «down»),
потому что поставщик crm.bp-gr.ru кладёт телефон/URL в поле «project» CSV.
Парсер extractPlatform() корректно их скипал, но строки оставались и в
count(missing), и в total_csv_rows формулы drift'а → стабильный false-positive.
Фикс (вариант A из брейнсторма с заказчиком):
- schema v8.36: +supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0
- CsvReconcileJob: считает $unparseableCount отдельно, новая формула
drift = max(0, missing − unparseable) / max(1, total − unparseable)
- Миграция (pgsql_supplier, Спек B pattern, IF NOT EXISTS — idempotent)
- TDD: +2 теста (100matched+10junk → ok; mixed 95+5junk+3real → drift по реальным).
Существующие 7 кейсов GREEN без изменений (unparseable=0 → формула идентична).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CsvReconcileJobTest used Bus::fake() (all jobs), silencing dispatch_sync of
RefreshSupplierSessionJob when a parallel afterEach wiped supplier:session.
Now: Bus::fake([RouteSupplierLeadJob::class]) + anonymous mock that re-puts
the session in handle(), making race-window recovery deterministic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>