fix(supplier): убрать false-positive drift_alert от мусора в CSV (Спек A)

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>
This commit is contained in:
Дмитрий
2026-05-25 08:38:31 +03:00
parent 0817c81e67
commit 3eb6c7fecd
5 changed files with 177 additions and 3 deletions
+13 -1
View File
@@ -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,
];
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
* за окно reconcile, у которых поле «project» не парсится в платформу
* (поставщик иногда кладёт телефон/URL в Name extractPlatform = null,
* строка скипается в csv_reconcile.unparseable_project_skipped).
*
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
*
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
* (BYPASSRLS) pgsql_supplier connection.
*/
return new class extends Migration
{
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->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);
}
};
@@ -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');
});
+29 -1
View File
@@ -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`
+7 -1
View File
@@ -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')),