diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index ddbfe235..1639c15f 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -148,6 +148,28 @@ class RouteSupplierLeadJob implements ShouldQueue $projectField = (string) ($lead->raw_payload['project'] ?? ''); [$platform, $signalType, $identifier] = $this->parseProjectField($projectField); + // Defense-in-depth (2-й инцидент в csv_recovery-канале, 16.07.2026): у звонкового + // сигнала телефон звонившего НИКОГДА не равен номеру-ловушке проекта ($identifier). + // Равенство = подпись порчи данных (регресс HTML-парсера «Мои сделки» и т.п.) в ЛЮБОМ + // канале/пути. Не создаём сделку и не биллим клиента за мусорный номер; метим лид + // (processed_at, чтобы не было retry-шторма) и шлём warning, чтобы регресс не спрятался. + if ($signalType === 'call' && (string) $lead->phone === $identifier) { + $lead->update([ + 'processed_at' => now(), + 'deals_created_count' => 0, + 'error' => 'caller_phone_equals_tracking_number [rejected by RouteSupplierLeadJob]', + ]); + Log::warning('supplier_lead.caller_equals_tracking_number', [ + 'supplier_lead_id' => $lead->id, + 'vid' => $lead->vid, + 'identifier' => $identifier, + 'source' => $lead->source, + 'platform' => $platform, + ]); + + return; + } + $supplier = $resolver->resolveOrStub($platform, $signalType, $identifier); $lead->update(['supplier_project_id' => $supplier->id]); diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php index 91874ec4..53c4c8f3 100644 --- a/app/app/Services/Supplier/SupplierPortalClient.php +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -135,7 +135,7 @@ class SupplierPortalClient // «Мои сделки» идёт РАНЬШЕ телефона звонившего. Берём первый 7\d{10}, НЕ равный // номеру проекта, иначе номер-ловушка попадёт в сделку вместо звонившего // (прод-инцидент 16.07.2026, tenant 7 — 11 сделок с номером проекта). - $projectNumber = preg_match('/^B[123]_(7\d{10})$/', $project, $pn) === 1 ? $pn[1] : null; + $projectNumber = preg_match('/^B[123]_(7\d{10})/', $project, $pn) === 1 ? $pn[1] : null; if (preg_match_all('/(7\d{10})/', $row, $phones) < 1) { continue; diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index 34b9b1c7..f46d3c5a 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -65,6 +65,55 @@ it('is terminal (does not throw / re-queue) when the supplier lead does not exis expect(DB::table('deals')->count())->toBe($countBefore); }); +it('rejects a call lead whose phone equals the tracking number (corruption guard: no deal, no charge)', function (): void { + // Defense-in-depth после прод-инцидента 16.07.2026 (2-й инцидент в csv_recovery-канале): + // у звонкового сигнала телефон звонившего НИКОГДА не равен номеру-ловушке проекта. + // Равенство = подпись порчи (регресс HTML-парсера и т.п.) → не создаём сделку, не биллим. + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B2', + 'signal_type' => 'call', + 'unique_key' => '74950009988', + ]); + $t = Tenant::factory()->create(['balance_rub' => '100000.00']); + $project = Project::factory()->create([ + 'tenant_id' => $t->id, + 'supplier_b2_project_id' => $supplier->id, + 'signal_type' => 'call', + 'signal_identifier' => '74950009988', + 'is_active' => true, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + ]); + linkProjectToSupplier($project, $supplier); + createRoutingSnapshotFromProject($project); + + $vid = 555000111; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B2', + 'vid' => $vid, + 'phone' => '74950009988', // == номер-ловушка (подпись порчи) + 'source' => 'csv_recovery', + 'raw_payload' => [ + 'vid' => $vid, + 'project' => 'B2_74950009988', + 'phone' => '74950009988', + ], + ]); + + $dealsBefore = DB::table('deals')->count(); + $chargesBefore = DB::table('lead_charges')->count(); + + runRouteJob($lead->id); + + $lead->refresh(); + expect(DB::table('deals')->count())->toBe($dealsBefore); // сделка НЕ создана + expect(DB::table('lead_charges')->count())->toBe($chargesBefore); // не списано + expect($lead->processed_at)->not->toBeNull(); // помечен (нет retry-шторма) + expect($lead->deals_created_count)->toBe(0); + expect($lead->error)->toContain('caller_phone_equals_tracking_number'); +}); + it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1',