diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 5d00305d..8256e541 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -126,11 +126,15 @@ final class CsvReconcileJob implements ShouldQueue $missing = array_diff_key($csvByKey, $existingKeys); $recoveredCount = 0; + $unparseableCount = 0; foreach ($missing as $row) { $platform = $this->extractPlatform((string) $row['project']); if ($platform === null) { // Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL). // Не warning — это не наш баг, processing продолжается, paper-trail на info уровне. + // Считаем такие строки отдельно, чтобы исключить из формулы drift'а + // (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert). + $unparseableCount++; Log::info('csv_reconcile.unparseable_project_skipped', [ 'project' => $row['project'], ]); @@ -161,7 +165,14 @@ final class CsvReconcileJob implements ShouldQueue } $matchedCount = $totalCsvRows - count($missing); - $driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0; + // drift считается только по «реальным» пропускам (parseable, не junk): + // real_missing = count(missing) - unparseable (всегда ≥ 0) + // parseable_tot = total_csv_rows - unparseable + // Это убирает класс «поставщик кладёт телефон/URL в поле project → + // строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05). + $realMissing = max(0, count($missing) - $unparseableCount); + $parseableTotal = max(0, $totalCsvRows - $unparseableCount); + $driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0; $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok'; $update = [ @@ -169,6 +180,7 @@ final class CsvReconcileJob implements ShouldQueue 'total_csv_rows' => $totalCsvRows, 'matched_count' => $matchedCount, 'recovered_count' => $recoveredCount, + 'unparseable_count' => $unparseableCount, 'drift_ratio' => $driftRatio, 'status' => $status, ]; diff --git a/app/database/migrations/2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log.php b/app/database/migrations/2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log.php new file mode 100644 index 00000000..932deeea --- /dev/null +++ b/app/database/migrations/2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log.php @@ -0,0 +1,51 @@ +getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) { + return; + } + + $conn->unprepared(<<<'SQL' + ALTER TABLE supplier_csv_reconcile_log + ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0; + SQL); + } + + public function down(): void + { + $conn = DB::connection('pgsql_supplier'); + + if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) { + return; + } + + $conn->unprepared(<<<'SQL' + ALTER TABLE supplier_csv_reconcile_log + DROP COLUMN IF EXISTS unparseable_count; + SQL); + } +}; diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index 26a9bb99..c0cb63c2 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -257,3 +257,80 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun expect($log->status)->toBe('failed'); expect($log->error_message)->toContain('500'); }); + +it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void { + // 100 нормальных webhook-лидов. + for ($i = 0; $i < 100; $i++) { + SupplierLead::create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), + 'vid' => 840000 + $i, + 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], + 'received_at' => now()->subHour(), + 'source' => 'webhook', + ]); + } + + // CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null). + // Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ). + $rows = []; + for ($i = 0; $i < 100; $i++) { + $rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; + } + for ($j = 0; $j < 10; $j++) { + $rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j]; + } + fakeReportFlow(csvBody($rows)); + + runCsvReconcile(); + + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->total_csv_rows)->toBe(110); + expect((int) $log->matched_count)->toBe(100); + expect((int) $log->recovered_count)->toBe(0); + expect((int) $log->unparseable_count)->toBe(10); + // Реального missing'а нет — только junk; drift должен быть 0, не 10/110. + expect((float) $log->drift_ratio)->toBe(0.0); + expect($log->status)->toBe('ok'); + + Mail::assertNothingSent(); +}); + +it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void { + for ($i = 0; $i < 95; $i++) { + SupplierLead::create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), + 'vid' => 850000 + $i, + 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], + 'received_at' => now()->subHour(), + 'source' => 'webhook', + ]); + } + + $rows = []; + for ($i = 0; $i < 95; $i++) { + $rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; + } + for ($j = 0; $j < 5; $j++) { + $rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j]; + } + for ($k = 0; $k < 3; $k++) { + $rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k]; + } + fakeReportFlow(csvBody($rows)); + + runCsvReconcile(); + + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->total_csv_rows)->toBe(103); + expect((int) $log->matched_count)->toBe(95); + expect((int) $log->recovered_count)->toBe(3); + expect((int) $log->unparseable_count)->toBe(5); + // real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok. + expect((float) $log->drift_ratio)->toBeLessThan(0.05); + expect((float) $log->drift_ratio)->toBeGreaterThan(0.0); + expect($log->status)->toBe('ok'); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 8ca1d31c..d75a0b58 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,34 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля). + +## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк + +Поставщик `crm.bp-gr.ru` периодически кладёт телефон/URL в поле «project» CSV-выгрузки +«Запрос номеров». Парсер `CsvReconcileJob` корректно их скипает (`extractPlatform()` → `null`), +но раньше эти строки попадали и в числитель `count($missing)`, и в знаменатель `total_csv_rows` +формулы drift'а → стабильный false-positive `drift_alert` ~40-50% при каждом hourly-запуске +(на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»). + +**Добавлено:** + +- **Колонка `supplier_csv_reconcile_log.unparseable_count` INTEGER NOT NULL DEFAULT 0** — кол-во + CSV-строк за окно, у которых `project` не парсится в платформу B1/B2/B3. + +**Изменено:** + +- `CsvReconcileJob`: считает `$unparseableCount` отдельно, новая формула + `drift_ratio = max(0, missing − unparseable) / max(1, total − unparseable)` — + только «реальные» пропуски от parseable-строк, без вклада junk'а. + +**Метрики:** +1 колонка. (Сверять с header `db/schema.sql`.) Таблиц / индексов / RLS — без изменений. + +**Миграция:** `2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log` (idempotent +`ADD COLUMN IF NOT EXISTS` на `pgsql_supplier` connection — Спек B pattern). + +**Тесты:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — +2 кейса (100 matched + +10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле). ## v8.35 (2026-05-24) — legacy direct webhook removal @@ -32,6 +59,7 @@ **Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts` **Связанные изменения кода:** + - `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log` - `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log` diff --git a/db/schema.sql b/db/schema.sql index 4f2f7be6..4fee7666 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,7 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал)) +-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project) +-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал)) -- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) -- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings) -- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) @@ -1137,6 +1138,11 @@ CREATE TABLE supplier_csv_reconcile_log ( total_csv_rows INTEGER, matched_count INTEGER, recovered_count INTEGER, + -- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3 + -- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта). + -- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания + -- этих строк формула стабильно даёт false-positive drift_alert ~40-50%. + unparseable_count INTEGER NOT NULL DEFAULT 0, drift_ratio NUMERIC(5,4), status VARCHAR(16) NOT NULL DEFAULT 'running' CHECK (status IN ('running','ok','drift_alert','failed')),