3eb6c7fecd
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>
337 lines
12 KiB
PHP
337 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Exceptions\Supplier\SupplierTransientException;
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use App\Jobs\Supplier\CsvReconcileJob;
|
||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||
use App\Mail\CsvDriftAlertMail;
|
||
use App\Models\SupplierLead;
|
||
use App\Services\Supplier\SupplierCsvParser;
|
||
use App\Services\Supplier\SupplierPortalClient;
|
||
use Illuminate\Contracts\Mail\Mailer;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Http\Client\Request;
|
||
use Illuminate\Support\Facades\Bus;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
function putSupplierSession(): void
|
||
{
|
||
Cache::store('redis')->put(
|
||
'supplier:session',
|
||
['phpsessid' => 'test', 'csrf' => 'test'],
|
||
now()->addHour(),
|
||
);
|
||
}
|
||
|
||
beforeEach(function (): void {
|
||
Mail::fake();
|
||
Bus::fake([RouteSupplierLeadJob::class]);
|
||
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
|
||
{
|
||
public function handle(): void
|
||
{
|
||
putSupplierSession();
|
||
}
|
||
});
|
||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||
putSupplierSession();
|
||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
|
||
});
|
||
|
||
afterEach(function (): void {
|
||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||
});
|
||
|
||
/**
|
||
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
|
||
*
|
||
* @param array<int, array{project: string, phone: string}> $rows
|
||
*/
|
||
function csvBody(array $rows): string
|
||
{
|
||
$out = "Name;Tag;Phone\n";
|
||
foreach ($rows as $r) {
|
||
$out .= "{$r['project']};tag;{$r['phone']}\n";
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19):
|
||
* POST /admin/report/save-report → "OK"
|
||
* GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match)
|
||
* GET /admin/report/getfile?id=N → raw CSV
|
||
*
|
||
* Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body
|
||
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
|
||
*/
|
||
function fakeReportFlow(string $csv): void
|
||
{
|
||
$captured = ['from' => '', 'to' => ''];
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
|
||
$body = $r->data();
|
||
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
|
||
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
|
||
|
||
return Http::response('OK', 200);
|
||
},
|
||
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
|
||
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
|
||
|
||
return Http::response([
|
||
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
|
||
], 200);
|
||
},
|
||
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
|
||
]);
|
||
}
|
||
|
||
function runCsvReconcile(): void
|
||
{
|
||
app(CsvReconcileJob::class)->handle(
|
||
app(SupplierPortalClient::class),
|
||
app(SupplierCsvParser::class),
|
||
app(Mailer::class),
|
||
);
|
||
}
|
||
|
||
it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||
for ($i = 0; $i < 10; $i++) {
|
||
SupplierLead::create([
|
||
'supplier_project_id' => null,
|
||
'platform' => 'B1',
|
||
'phone' => "7999000000{$i}",
|
||
'vid' => 800000 + $i,
|
||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
|
||
'received_at' => now()->subHour(),
|
||
'source' => 'webhook',
|
||
]);
|
||
}
|
||
|
||
$rows = [];
|
||
for ($i = 0; $i < 10; $i++) {
|
||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
|
||
}
|
||
fakeReportFlow(csvBody($rows));
|
||
|
||
runCsvReconcile();
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
expect($log->status)->toBe('ok');
|
||
expect((int) $log->total_csv_rows)->toBe(10);
|
||
expect((int) $log->matched_count)->toBe(10);
|
||
expect((int) $log->recovered_count)->toBe(0);
|
||
|
||
Mail::assertNothingSent();
|
||
Bus::assertNothingDispatched();
|
||
});
|
||
|
||
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||
for ($i = 0; $i < 9; $i++) {
|
||
SupplierLead::create([
|
||
'supplier_project_id' => null,
|
||
'platform' => 'B1',
|
||
'phone' => "7999111000{$i}",
|
||
'vid' => 810000 + $i,
|
||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
|
||
'received_at' => now()->subHour(),
|
||
'source' => 'webhook',
|
||
]);
|
||
}
|
||
|
||
$rows = [];
|
||
for ($i = 0; $i < 10; $i++) {
|
||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
|
||
}
|
||
fakeReportFlow(csvBody($rows));
|
||
|
||
runCsvReconcile();
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
expect($log->status)->toBe('drift_alert');
|
||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
|
||
expect((int) $log->recovered_count)->toBe(1);
|
||
|
||
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
|
||
expect($recovered)->not->toBeNull();
|
||
expect($recovered->vid)->toBeNull();
|
||
expect($recovered->recovered_from_csv_at)->not->toBeNull();
|
||
|
||
Mail::assertSent(CsvDriftAlertMail::class, 1);
|
||
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
|
||
});
|
||
|
||
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
|
||
for ($i = 0; $i < 99; $i++) {
|
||
SupplierLead::create([
|
||
'supplier_project_id' => null,
|
||
'platform' => 'B1',
|
||
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||
'vid' => 820000 + $i,
|
||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||
'received_at' => now()->subHour(),
|
||
'source' => 'webhook',
|
||
]);
|
||
}
|
||
|
||
$rows = [];
|
||
for ($i = 0; $i < 100; $i++) {
|
||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||
}
|
||
fakeReportFlow(csvBody($rows));
|
||
|
||
runCsvReconcile();
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
expect($log->status)->toBe('ok');
|
||
expect((int) $log->recovered_count)->toBe(1);
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
|
||
SupplierLead::create([
|
||
'supplier_project_id' => null,
|
||
'platform' => 'B1',
|
||
'phone' => '79995550000',
|
||
'vid' => 830000,
|
||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||
'received_at' => now()->subHour(),
|
||
'source' => 'webhook',
|
||
]);
|
||
|
||
fakeReportFlow(csvBody([
|
||
['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||
['project' => 'B2_b.com', 'phone' => '79995550000'],
|
||
]));
|
||
|
||
runCsvReconcile();
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
expect((int) $log->matched_count)->toBe(1);
|
||
expect((int) $log->recovered_count)->toBe(1);
|
||
});
|
||
|
||
it('empty CSV — status=ok, drift=0', function (): void {
|
||
fakeReportFlow("Name;Tag;Phone\n");
|
||
|
||
runCsvReconcile();
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
expect($log->status)->toBe('ok');
|
||
expect((int) $log->total_csv_rows)->toBe(0);
|
||
});
|
||
|
||
it('overlap lock held — job skips, no log row', function (): void {
|
||
$countBefore = DB::table('supplier_csv_reconcile_log')->count();
|
||
|
||
$lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600);
|
||
$lock->get();
|
||
|
||
try {
|
||
runCsvReconcile();
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
|
||
});
|
||
|
||
it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
|
||
Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);
|
||
|
||
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
|
||
|
||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||
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');
|
||
});
|