a43ac2d9a5
After the existing webhook-loss drift detection (R-05.1: lead delivered but
webhook missed), CsvReconcileJob now runs a second pass on project_routing_snapshots:
per (snapshot_date, tenant_id) groups, if (expected - delivered) / expected > 20%
→ send TenantBusinessDriftAlertMail (separate from CsvDriftAlertMail).
This catches R-05.2: lead expected by slepok plan but supplier under-delivered.
Same lead can be missing from both CSV (webhook-loss) AND delivered_count
(business-shortfall) — both alerts fire independently.
BUSINESS_DRIFT_THRESHOLD = 0.20
detectAndAlertBusinessDrift() — runs after primary drift inside try{} block,
scoped to the same reconcile window. One email per tenant per snapshot_date.
+ New TenantBusinessDriftAlertMail + emails/tenant_business_drift_alert.blade.php.
+ 2 Pest tests: shortfall>20% triggers mail (80% case), shortfall<=20% does not (10% case).
+ Existing tests narrowed from assertNothingSent() to assertNotSent(CsvDriftAlertMail)
since legacy snapshot data on dev DB may trigger TenantBusinessDriftAlertMail
beyond test's scope.
Full CsvReconcileJobTest suite 11/11 GREEN. Stage 4 §4.4.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
416 lines
16 KiB
PHP
416 lines
16 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::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
|
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::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
|
});
|
|
|
|
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).
|
|
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
|
|
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
|
|
$rows = [];
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
|
}
|
|
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
|
|
foreach ($junkProjects as $j => $junk) {
|
|
$rows[] = ['project' => $junk, '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::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
|
});
|
|
|
|
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)];
|
|
}
|
|
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
|
|
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
|
|
foreach ($junkProjects as $j => $junk) {
|
|
$rows[] = ['project' => $junk, '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');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass.
|
|
// After existing webhook-loss drift detection, CsvReconcileJob runs a second
|
|
// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups
|
|
// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail.
|
|
// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be:
|
|
// - delivered & webhook OK (no alerts)
|
|
// - delivered & webhook miss (R-05.1 CsvDriftAlertMail)
|
|
// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
|
|
{
|
|
$tenant = \App\Models\Tenant::find($tenantId) ?? \App\Models\Tenant::factory()->create();
|
|
$project = \App\Models\Project::factory()
|
|
->for($tenant)
|
|
->asCallSignal('7977'.\Illuminate\Support\Str::random(7))
|
|
->create([
|
|
'is_active' => true,
|
|
'daily_limit_target' => max($expected, 1),
|
|
]);
|
|
\Illuminate\Support\Facades\DB::connection('pgsql_supplier')
|
|
->table('project_routing_snapshots')
|
|
->insert([
|
|
'snapshot_date' => $date,
|
|
'project_id' => $project->id,
|
|
'tenant_id' => $tenant->id,
|
|
'daily_limit' => max($expected, 1),
|
|
'delivery_days_mask' => 127,
|
|
'regions' => '{}',
|
|
'signal_type' => 'call',
|
|
'signal_identifier' => $project->signal_identifier,
|
|
'sms_senders' => null,
|
|
'sms_keyword' => null,
|
|
'expected_volume' => $expected,
|
|
'delivered_count' => $delivered,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
|
|
$tenant = \App\Models\Tenant::factory()->create();
|
|
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
|
|
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
|
|
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
|
|
|
|
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
|
|
fakeReportFlow(csvBody([]));
|
|
runCsvReconcile();
|
|
|
|
Mail::assertSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
|
return $mail->tenantId === $tenant->id
|
|
&& $mail->expected === 10
|
|
&& $mail->delivered === 2
|
|
&& $mail->shortfallRatio >= 0.79
|
|
&& $mail->shortfallRatio <= 0.81;
|
|
});
|
|
});
|
|
|
|
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
|
|
$tenant = \App\Models\Tenant::factory()->create();
|
|
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
|
|
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
|
|
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
|
|
|
|
fakeReportFlow(csvBody([]));
|
|
runCsvReconcile();
|
|
|
|
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
|
|
// this test only owns one tenant, so assert no mail was sent for IT.
|
|
Mail::assertNotSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
|
return $mail->tenantId === $tenant->id;
|
|
});
|
|
});
|