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'); }); /** * Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads * возвращает лиды, ключ = vid (как реальный метод). * * @param list $leads */ function fakeDelivered(array $leads): void { $byVid = []; foreach ($leads as $l) { $byVid[(int) $l['vid']] = $l; } $mock = Mockery::mock(SupplierPortalClient::class); $mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid); app()->instance(SupplierPortalClient::class, $mock); } function fakeDeliveredThrows(Throwable $e): void { $mock = Mockery::mock(SupplierPortalClient::class); /* @phpstan-ignore-next-line method.notFound (Mockery andThrow — union-type false positive, как в др. supplier-тестах) */ $mock->shouldReceive('fetchDeliveredLeads')->andThrow($e); app()->instance(SupplierPortalClient::class, $mock); } /** Принятый вебхуком лид (source=webhook, реальный vid). */ function webhookLead(int $vid, string $phone, string $project = 'B1_a.com', ?Carbon $at = null): void { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => str_starts_with($project, 'B') ? substr($project, 0, 2) : 'B1', 'phone' => $phone, 'vid' => $vid, 'raw_payload' => ['project' => $project, 'phone' => $phone, 'vid' => $vid], 'received_at' => $at ?? now()->subHour(), 'source' => 'webhook', ]); } function runCsvReconcile(): void { app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(Mailer::class), ); } // --------------------------------------------------------------------------- // Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026. // Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита. // --------------------------------------------------------------------------- it('all delivered vids already received via webhook — recovered 0, status ok', function (): void { $delivered = []; for ($i = 0; $i < 10; $i++) { $vid = 800000 + $i; $phone = "7999000000{$i}"; webhookLead($vid, $phone); $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; } fakeDelivered($delivered); 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); Bus::assertNothingDispatched(); }); it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void { $delivered = []; for ($i = 0; $i < 9; $i++) { $vid = 810000 + $i; $phone = "7999111000{$i}"; webhookLead($vid, $phone); $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; } // 10-й отдан поставщиком, но вебхук его потерял. $missingVid = 810099; $missingPhone = '79991119999'; $delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com']; fakeDelivered($delivered); 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(); // КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: recovery несёт НАСТОЯЩИЙ vid (раньше был null) — точная личность лида. expect((int) $recovered->vid)->toBe($missingVid); expect($recovered->phone)->toBe($missingPhone); expect($recovered->recovered_from_csv_at)->not->toBeNull(); Mail::assertSent(CsvDriftAlertMail::class, 1); Bus::assertDispatched(RouteSupplierLeadJob::class, 1); }); it('reconciles ONLY the delivered ledger — pool numbers (roistat identity) are structurally impossible to recover', function (): void { // Поставщик реально ОТДАЛ 3 лида (vid+phone), вебхук их принял. // В старом баге ПУЛ по этому проекту содержал десятки ДРУГИХ телефонов (пул 157 vs отдано 15), // и старый reconcile лепил из них фантомы. Новый источник = только отданное (по vid) → лишнему // взяться неоткуда. $delivered = []; foreach ([[900001, '79000000001'], [900002, '79000000002'], [900003, '79000000003']] as [$vid, $phone]) { webhookLead($vid, $phone, 'B2_74950001122'); $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B2_74950001122']; } fakeDelivered($delivered); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->recovered_count)->toBe(0); expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); Bus::assertNothingDispatched(); }); it('delivered vid already exists (any time) — matched, not re-recovered, no duplicate', function (): void { // Лид принят вебхуком 3 дня назад (вне 2-дневного окна reconcile). В журнале отданного он ещё // виден. Глобальная проверка vid должна засчитать его как matched, а не пытаться вставить дубль. $vid = 920000; $phone = '79200000000'; webhookLead($vid, $phone, 'B1_a.com', now()->subDays(3)); fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); runCsvReconcile(); expect(SupplierLead::where('vid', $vid)->count())->toBe(1); // без дубля expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->matched_count)->toBe(1); }); it('unparseable project in delivered ledger — skipped, counted, excluded from drift', function (): void { // 5 нормальных отданных (webhook принял) + 5 отданных с мусорным project (extractPlatform=null). $delivered = []; for ($i = 0; $i < 5; $i++) { $vid = 930000 + $i; $phone = "7993000000{$i}"; webhookLead($vid, $phone); $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; } $junk = ['???', '!@#', '%%%', '$$$', '***']; foreach ($junk as $j => $bad) { $delivered[] = ['vid' => 931000 + $j, 'phone' => "7993100000{$j}", 'project' => $bad]; } fakeDelivered($delivered); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->total_csv_rows)->toBe(10); expect((int) $log->matched_count)->toBe(5); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->unparseable_count)->toBe(5); expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет expect($log->status)->toBe('ok'); }); it('empty delivered ledger — status=ok, drift=0', function (): void { fakeDelivered([]); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->total_csv_rows)->toBe(0); expect((int) $log->recovered_count)->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 from delivered fetch — status=failed, rethrown', function (): void { fakeDeliveredThrows(new SupplierTransientException('Supplier 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'); }); // --------------------------------------------------------------------------- // fetchDeliveredLeads — парсинг HTML «Мои сделки» + пагинация (реальный клиент, Http::fake). // --------------------------------------------------------------------------- /** * Строит HTML таблицы «Мои сделки»: строка = checkbox value(vid) + B{n}_ + телефон. * * @param list $leads */ function deliveredHtml(array $leads): string { $rows = ''; foreach ($leads as $l) { $rows .= '' .'' .'Открыть '.$l['project'].' РФ'.$l['phone'].' -' .''; } return ''.$rows.'
'; } it('fetchDeliveredLeads parses vid+phone+project from Мои сделки HTML', function (): void { $html = deliveredHtml([ ['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com'], ['vid' => 1718932472, 'phone' => '79001112244', 'project' => 'B2_74950001122'], ]); Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]); $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); expect($result)->toHaveCount(2); expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']); expect($result[1718932472]['project'])->toBe('B2_74950001122'); }); it('fetchDeliveredLeads picks the real caller phone, not the phone-shaped B2/B3 project number', function (): void { // B2/B3 (Билайн/МТС) проекты названы номером-ловушкой (7\d{10}), который в строке «Мои // сделки» идёт РАНЬШЕ телефона звонившего. Прод-инцидент 16.07.2026 (tenant 7): парсер // хватал номер-ловушку как телефон → сделки с номером проекта вместо звонившего. $html = deliveredHtml([ ['vid' => 5001, 'phone' => '79008887766', 'project' => 'B2_74950009988'], ['vid' => 5002, 'phone' => '79007776655', 'project' => 'B3_74990001122'], ]); Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]); $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); expect($result[5001]['phone'])->toBe('79008887766'); expect($result[5001]['project'])->toBe('B2_74950009988'); expect($result[5002]['phone'])->toBe('79007776655'); }); it('fetchDeliveredLeads paginates until a page returns < 50 rows', function (): void { $page1 = []; for ($i = 0; $i < 50; $i++) { $page1[] = ['vid' => 1000 + $i, 'phone' => '790000'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com']; } $page2 = []; for ($i = 0; $i < 3; $i++) { $page2[] = ['vid' => 2000 + $i, 'phone' => '790010'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com']; } Http::fake([ 'crm.bp-gr.ru/admin/visit/index-visit*' => Http::sequence() ->push(deliveredHtml($page1), 200) ->push(deliveredHtml($page2), 200), ]); $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице }); // --------------------------------------------------------------------------- // R-05 business-drift (spec §4.4.4) — второй проход по project_routing_snapshots. // Ортогонален webhook-loss drift: тот же лид может быть не доставлен вовсе. // --------------------------------------------------------------------------- function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void { $tenant = Tenant::find($tenantId) ?? Tenant::factory()->create(); $project = Project::factory() ->for($tenant) ->asCallSignal('7977'.Str::random(7)) ->create([ 'is_active' => true, 'daily_limit_target' => max($expected, 1), ]); 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 = Tenant::factory()->create(); $yesterday = Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 2); fakeDelivered([]); runCsvReconcile(); Mail::assertSent(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 = Tenant::factory()->create(); $yesterday = Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 9); fakeDelivered([]); runCsvReconcile(); Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { return $mail->tenantId === $tenant->id; }); });