harden(supplier): защита-инвариант «телефон звонившего != номер-ловушка» + робастный парсер

Defense-in-depth после 2-го инцидента в csv_recovery-канале (16.07.2026):
- RouteSupplierLeadJob: чокпоинт обоих путей (webhook+csv_recovery) — если у звонкового
  сигнала phone == identifier (номер-ловушка проекта), сделка НЕ создаётся и клиент НЕ
  списывается; лид метится processed_at+error, шлётся warning. Ловит любой будущий регресс.
- SupplierPortalClient.parseDeliveredRows: извлечение номера проекта без якоря $ —
  ловит номер-ловушку даже с хвостовыми символами.
Тесты RED->GREEN (guard 5 assertions), 45/45 route+csv, Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-16 07:05:27 +03:00
parent 1dc8900cec
commit e9ee67e4cd
3 changed files with 72 additions and 1 deletions
+22
View File
@@ -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]);
@@ -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;
@@ -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',