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:
@@ -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,
|
||||
];
|
||||
|
||||
+51
@@ -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
@@ -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
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user