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'); Cache::store('redis')->forget('supplier:csv_reconcile:pending'); 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'); Cache::store('redis')->forget('supplier:csv_reconcile:pending'); }); /** * Мокает журнал ОТДАННОГО (Мои сделки): 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); // Отсрочка 31.07.2026: первый прогон только кладёт недостачу в карантин, добор — со // второго, когда стало ясно, что вебхук её не несёт (а не просто опаздывает). runCsvReconcile(); Carbon::setTestNow(now()->addMinutes(16)); 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); // Мусорные строки — тоже недостача, поэтому проходят через отсрочку: считаются // unparseable только на прогоне, где реально дошло до разбора project. runCsvReconcile(); Carbon::setTestNow(now()->addMinutes(16)); 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((int) $log->pending_count)->toBe(0); 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; }); }); // --------------------------------------------------------------------------- // Отсрочка добора (31.07.2026): вебхук поставщика приходит на 2-8 минут ПОЗЖЕ, // чем строка появляется в журнале отданного. Сверка каждые 30 мин попадала в это // окно и «восстанавливала» лид, который уже был в пути → ложная тревога «потеряно N» // + бедная карточка (в журнале нет tag/time/phones). Инцидент 31.07: 7 добранных, // вебхук по тем же семи пришёл через 2,5 минуты и получил «уже есть». // Правило: недостача добирается только если висит дольше GRACE_MINUTES. // --------------------------------------------------------------------------- it('first sighting of a missing vid is quarantined — not recovered, no alert', function (): void { $vid = 930001; $phone = '79300000001'; fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); runCsvReconcile(); expect(SupplierLead::where('vid', $vid)->count())->toBe(0); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->pending_count)->toBe(1); expect($log->status)->toBe('ok'); Mail::assertNotSent(CsvDriftAlertMail::class); Bus::assertNothingDispatched(); }); it('late webhook arrives during quarantine — nothing is recovered, no phantom, no alert', function (): void { // Точный сценарий инцидента 31.07.2026: в 05:30 сверка увидела 7 номеров, которых у нас // нет; вебхук по ним пришёл в 05:32-05:33. Со старым поведением портал уже завёл их сам // (бедные карточки, ложная тревога «потеряно 7»). С отсрочкой — ждём и не трогаем. $vid = 940001; $phone = '79400000001'; fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); runCsvReconcile(); // 05:30 — увидели недостачу, в карантин webhookLead($vid, $phone, 'B1_a.com'); // 05:33 — вебхук донёс, как в жизни Carbon::setTestNow(now()->addMinutes(16)); runCsvReconcile(); // 06:00 — недостачи больше нет expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); expect(SupplierLead::where('vid', $vid)->count())->toBe(1); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->pending_count)->toBe(0); expect((int) $log->matched_count)->toBe(1); expect($log->status)->toBe('ok'); Mail::assertNotSent(CsvDriftAlertMail::class); Bus::assertNothingDispatched(); }); it('alert counts only overdue as lost and reports in-flight separately', function (): void { // Один номер висит с прошлого прогона (реальная потеря), второй увиден только сейчас (в пути). $lostVid = 950001; $lostPhone = '79500000001'; fakeDelivered([['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com']]); runCsvReconcile(); Carbon::setTestNow(now()->addMinutes(16)); $freshVid = 950002; $freshPhone = '79500000002'; fakeDelivered([ ['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com'], ['vid' => $freshVid, 'phone' => $freshPhone, 'project' => 'B1_a.com'], ]); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->recovered_count)->toBe(1); expect((int) $log->pending_count)->toBe(1); Mail::assertSent(CsvDriftAlertMail::class, function (CsvDriftAlertMail $mail) { return $mail->missingCount === 1 // потерей считаем только просроченный && $mail->pendingCount === 1 // «в пути» показываем отдельно && $mail->recoveredCount === 1; }); expect(SupplierLead::where('vid', $freshVid)->count())->toBe(0); }); it('drift alert email renders and separates loss from in-flight', function (): void { // Смоук шаблона: битый blade иначе всплыл бы только на бою, письмом-пустышкой. $html = (new CsvDriftAlertMail( reconcileLogId: 1417, totalCsvRows: 21, missingCount: 7, pendingCount: 3, recoveredCount: 7, driftRatio: 0.3333, windowStart: Carbon::parse('2026-07-30 00:00:00'), windowEnd: Carbon::parse('2026-07-31 02:30:00'), ))->render(); expect($html)->toContain('Ещё в пути'); expect($html)->toContain('считаем потерей'); expect($html)->toContain('1417'); expect($html)->not->toContain('Пропущено webhook'); }); /** * Разметка строки журнала «Мои сделки» — СПИСАНА С ЖИВОГО КАБИНЕТА (31.07.2026), * а не придумана: проект лежит в td.crm-domain-column, следом отдельная ячейка с ТЕГОМ * (регион или «РФ»), затем td.users__td_phones с телефоном звонившего. * * @param list $leads */ function deliveredHtmlCabinet(array $leads): string { $rows = ''; foreach ($leads as $l) { $vid = $l['vid']; $rows .= '' .'' .' '.$l['project'].' ' .''.$l['tag'].'' .'' .'' .''; } return ''.$rows.'
'; } it('fetchDeliveredLeads picks up the region tag from the cabinet row', function (): void { $html = deliveredHtmlCabinet([ ['vid' => 6001, 'phone' => '79990000001', 'project' => 'B1_79089202427', 'tag' => 'Свердловская область'], ['vid' => 6002, 'phone' => '79990000002', 'project' => 'B1_79537885612', 'tag' => 'РФ'], ['vid' => 6003, 'phone' => '79990000003', 'project' => 'B1_a.com', 'tag' => '-'], ]); Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]); $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); expect($result[6001]['tag'])->toBe('Свердловская область'); expect($result[6002]['tag'])->toBe('РФ'); expect($result[6003]['tag'])->toBeNull(); // прочерк — это не тег // телефон и проект по-прежнему берутся верно expect($result[6001]['phone'])->toBe('79990000001'); expect($result[6001]['project'])->toBe('B1_79089202427'); }); it('recovered lead carries the supplier tag — region has a chance when DaData is silent', function (): void { // Повод (31.07.2026): у добранного лида в карточке было только vid+phone+project, поэтому // резолверу нечем было подстраховаться, когда ДаData не знает номер. Тег поставщика — // ровно эта подстраховка (RegionTagResolver), и он в журнале ЕСТЬ, мы его выбрасывали. $vid = 960001; fakeDelivered([['vid' => $vid, 'phone' => '79600000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]); runCsvReconcile(); Carbon::setTestNow(now()->addMinutes(16)); runCsvReconcile(); $lead = SupplierLead::where('vid', $vid)->first(); expect($lead)->not->toBeNull(); expect($lead->raw_payload['tag'])->toBe('Красноярский край'); }); it('tag from the journal actually yields a region when DaData is silent', function (): void { // Смысл правки — не «положить строчку в карточку», а вернуть региону опору. // ДаData выключена (как будто не знает номер) → резолвер обязан взять регион из тега. config(['services.dadata.enabled' => false]); $vid = 970001; fakeDelivered([['vid' => $vid, 'phone' => '79700000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]); runCsvReconcile(); Carbon::setTestNow(now()->addMinutes(16)); runCsvReconcile(); $lead = SupplierLead::where('vid', $vid)->first(); $resolution = app(LeadRegionResolver::class)->resolve($lead); expect($resolution->source)->toBe('tag'); expect($resolution->subjectCode)->not->toBeNull(); // Контроль вырезанием: без тега тот же лид даёт «регион неизвестен». $lead->raw_payload = ['project' => 'B1_a.com', 'phone' => '79700000001', 'vid' => $vid]; $lead->save(); $without = app(LeadRegionResolver::class)->resolve($lead->fresh()); expect($without->source)->toBe('unknown'); expect($without->subjectCode)->toBeNull(); });