Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
T
Дмитрий 700fa5455a fix(лиды): кириллический домен больше не принимается за СМС — лид по .рф доезжает до клиента
Беда, пойманная на бою 10.08.2026: лид 683 по проекту 'B2_автозаим.рф' не
развёлся ни одному клиенту. Работа падала 35 раз с 10:35 до 16:02, потом сдалась
— лид остался недоставленным.

Причина не в поставщике и не в данных, а в разборе строки проекта. Тип сигнала
угадывался по виду строки: телефон — звонок, домен — сайт, всё остальное — СМС.
Но оба правила «это домен» были написаны только под латиницу: `[a-z0-9-]` и
окончание `[a-z]{2,}`. Строка `автозаим.рф` под них не подходила и проваливалась
в последнее правило «значит, СМС». А в справочнике поставщика тот же ключ
записан как 'site' — типы не сошлись, и разводчик отказался работать.

Замерено на бою: таких проектов пять, все на .рф — авто-займ-124.рф,
автозаим.рф, автоломбард-экспресс.рф, займ-24.рф, сфера-займа24.рф. Из них
включён один (автозаим.рф, план 15 лидов в день), остальные четыре — мины на
случай включения.

🔴 Это ТРЕТИЙ случай одной семьи. 18.05.2026 так же не распознался домен внутри
свободного текста ('B1_заявка carmoney.ru/') — тогда застряло 22 живых лида.
Оба раза причина одна: разбор судил по алфавиту, а не по строению строки.
Теперь правило одно и записано в коде объяснением: домен опознаётся по точке и
буквенному окончанию, буквы считаются любые (\p{L} + флаг u).

Граница проведена намеренно: кириллица сама по себе сайтом не считается. Слово
без точки ('АкцияЛето') остаётся СМС-ключом — иначе лечение было бы хуже
болезни. Охраняет отдельный тест.

Проверено: новые тесты сначала упали, причём три из четырёх — дословно с боевой
ошибкой. После правки 44/44 в тестах разводки и 467/467 по поставщику,
вебхукам, деньгам и имитации. Pint чист, статанализ по моим файлам чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:03:39 +03:00

873 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Mockery as M;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Plan 4 Task 4: LedgerService требует наличия активных PricingTier'ов для tier-resolve.
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
function runRouteJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void {
// Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1
// бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs ->
// RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей).
// «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен.
$missingId = 999999;
expect(SupplierLead::find($missingId))->toBeNull();
$countBefore = DB::table('deals')->count();
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
runRouteJob($missingId);
// Никаких побочных эффектов — количество сделок не изменилось.
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',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
$vid = 432176649;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_vashinvestor.ru',
'tag' => 'tag',
'phone' => '79991234567',
'phones' => ['79991234567'],
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(3);
expect($lead->supplier_project_id)->toBe($supplier->id);
foreach ($projects as $i => $p) {
$tenant = $tenants[$i];
$p->refresh();
expect($p->delivered_today)->toBe(1);
expect($p->delivered_in_month)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->where('source_crm_id', $vid)
->get();
expect($deals)->toHaveCount(1);
}
});
it('charges balance_rub for tenant after routing', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = 99;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('throws DomainException when payload encodes B1+SMS combo', function (): void {
$vid = 1;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
expect(fn () => runRouteJob($lead->id))->toThrow(DomainException::class);
});
it('handles orphan supplier_project (no matching liderra-projects) — 0 deals, lead processed', function (): void {
$vid = 777;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_orphan.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(0);
expect($lead->supplier_project_id)->not->toBeNull();
});
it('same phone pre-existing does not suppress new delivery (Spec B)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'mixed.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'mixed.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
$firstTenant = $tenants[0];
$firstProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'");
Deal::create([
'tenant_id' => $firstTenant->id,
'source_crm_id' => 555,
'project_id' => $firstProject->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => now()->subHours(2),
]);
$vid = 2222;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_mixed.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
// Spec B: pre-existing master does NOT suppress — all 3 charged.
expect($lead->deals_created_count)->toBe(3);
// All 3 tenants: balance decremented, delivered_today incremented.
foreach (range(0, 2) as $i) {
$t = $tenants[$i];
$p = $projects[$i];
expect((string) $t->fresh()->balance_rub)->toBe('99500.00');
expect($p->fresh()->delivered_today)->toBe(1);
}
// 3 deal rows exist for this vid (one per tenant).
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3);
});
it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
// BLOCKER #3 (CV.11 audit): RouteSupplierLeadJob::$tries = 3. На retry задачи (после
// транзиентного сбоя — DB hiccup, queue worker restart) handle() запускался ПОВТОРНО
// без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем
// же vid (DuplicateDetector помечал его как duplicate, без charge — но deal-row в БД
// оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал
// счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики.
//
// Fix: в начале handle() — if ($lead->processed_at !== null) return; — early return.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'retry-idempotent.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'retry-idempotent.ru',
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = 7777;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_retry-idempotent.ru',
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
// 1st run — нормальная обработка.
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(1);
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
// 2nd run — должен быть no-op (idempotent guard на processed_at).
runRouteJob($lead->id);
$lead->refresh();
// Лид остаётся помечен обработанным, deals_created_count НЕ сбросился.
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(1);
// НИКАКИХ дублей не появилось: balance, counter, deal-row.
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
});
it('handles partial failure: one project throws, others continue routing', function (): void {
// Тест полагается на Tenant SoftDeletes (см. App\Models\Tenant) — soft-delete
// tenant'а в середине loop'а заставляет Tenant::firstOrFail() выкинуть
// ModelNotFoundException, что симулирует per-Project failure без мокинга.
// Если SoftDeletes когда-либо удалят с Tenant — этот тест нужно переписать
// на runtime-mock или удалить (PHPStan не пропускает $this->markTestSkipped()
// внутри Pest-closure).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'partial-failure.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'partial-failure.ru',
'is_active' => true,
'delivered_today' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
$tenants[1]->delete();
$vid = 3333;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_partial-failure.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал
// Tenants 0 и 2 успешно списаны
expect((string) $tenants[0]->fresh()->balance_rub)->toBe('99500.00');
expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00');
});
it('fast-fails terminal-error lead without creating a deal or re-queueing', function (): void {
// Finding 2 (2026-05-29, fast-fail plan): лид с terminal-error и processed_at=null
// раньше прокручивался заново → failed_webhook_jobs storm. Fast-fail помечает его
// processed с суффиксом [fast-failed...], без сделки и без повторной очереди.
$countBefore = DB::table('deals')->count();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => 4444,
'phone' => '79991234599',
'raw_payload' => ['vid' => 4444, 'project' => 'B1_whatever.ru', 'phone' => '79991234599', 'time' => now()->getTimestamp()],
'processed_at' => null,
'error' => 'no matching supplier_project for B1/site/whatever.ru',
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->error)->toContain('[fast-failed by RouteSupplierLeadJob]');
expect(DB::table('deals')->count())->toBe($countBefore); // сделок не создано
});
it('throws RuntimeException when ALL eligible projects fail routing', function (): void {
// Терминальный all-fail (RouteSupplierLeadJob:188-193): selected непуст, но КАЖДЫЙ
// createDealCopyForProject бросает → createdCount=0 → RuntimeException (очередь
// ловит → retry → failed_webhook_jobs). Форсируем soft-delete'ом ВСЕХ тенантов:
// Tenant::firstOrFail() внутри createDealCopyForProject падает для каждого проекта
// (тот же приём, что и в partial-failure тесте, но на 100% проектов).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'all-fail.ru',
]);
$tenants = collect();
for ($i = 0; $i < 2; $i++) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$project = Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'all-fail.ru',
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
}
foreach ($tenants as $t) {
$t->delete(); // soft-delete → firstOrFail упадёт для каждого проекта
}
$vid = 5555;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234588',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_all-fail.ru', 'phone' => '79991234588', 'time' => now()->getTimestamp()],
]);
expect(fn () => runRouteJob($lead->id))->toThrow(RuntimeException::class);
});
it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
// Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный
// текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField
// c anchored-regex '^[a-z0-9-]+(\.[a-z0-9-]+)+$' такой rest не матчил → классифицировал
// как 'sms' → B1+sms → DomainException → 21 реальный лид застрял с error, 0 сделок.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => $domain,
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => $domain,
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => $projectField,
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->supplier_project_id)->toBe($supplier->id);
expect($lead->deals_created_count)->toBe(1);
})->with([
'carmoney embedded in free text' => ['B1_заявка carmoney.ru/', 'carmoney.ru'],
'caranga subdomain with path' => ['B1_Платежи cabinet.caranga.ru/login', 'cabinet.caranga.ru'],
'krk-finance with auth path' => ['B1_krk-finance.ru/cabinet/auth', 'krk-finance.ru'],
]);
// ---------------------------------------------------------------------------
// Кириллические домены (.рф) — третий случай той же болезни
//
// Живой инцидент 10.08.2026: лид 683 по проекту 'B2_автозаим.рф' не развёлся.
// Оба правила «это домен» написаны только под латиницу — `[a-z0-9-]` и окончание
// `[a-z]{2,}`. Кириллическая строка под них не подходит и проваливается в
// последнее правило «всё остальное = sms». В справочнике поставщика тот же ключ
// записан как 'site' → collision → DomainException → лид застрял, 35 повторов.
//
// Это ТРЕТИЙ случай одной семьи: 18.05.2026 так же не распознался домен в
// свободном тексте (21 застрявший лид, см. тест выше). Лечим корень — разбор
// должен понимать буквы, а не только латинские.
//
// На бою таких проектов пять: авто-займ-124.рф, автозаим.рф,
// автоломбард-экспресс.рф, займ-24.рф, сфера-займа24.рф.
// ---------------------------------------------------------------------------
it('routes B2 lead whose project is a Cyrillic domain (.рф)', function (string $projectField, string $domain): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => $domain,
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b2_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => $domain,
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B2',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => $projectField,
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull('лид не разведён')
->and($lead->supplier_project_id)->toBe($supplier->id, 'привязался не к тому проекту поставщика')
->and($lead->deals_created_count)->toBe(1, 'сделка не создана');
})->with([
'живой случай 10.08.2026' => ['B2_автозаим.рф', 'автозаим.рф'],
'дефис внутри имени' => ['B2_займ-24.рф', 'займ-24.рф'],
'домен в свободном тексте' => ['B2_заявка автозаим.рф/', 'автозаим.рф'],
'цифры в имени' => ['B2_сфера-займа24.рф', 'сфера-займа24.рф'],
]);
it('still treats a Cyrillic word without a domain as an SMS signal', function (): void {
// Граница: чинить надо распознавание ДОМЕНА, а не «раз кириллица — значит сайт».
// СМС-ключ без точки обязан остаться СМС-ключом, иначе лечение хуже болезни.
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B2',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B2_АкцияЛето',
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
$supplier = SupplierProject::find($lead->supplier_project_id);
expect($supplier)->not->toBeNull('проект поставщика не заведён')
->and($supplier->signal_type)->toBe('sms', 'кириллическое слово без точки принято за сайт')
->and($supplier->unique_key)->toBe('АкцияЛето');
});
it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void {
// BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit
// БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое
// инкрементит) — окно для concurrent webhook'а:
// worker A видит delivered_today=9, limit=10 → OK; createDealCopyForProject → 10.
// worker B параллельно видит то же 9 → OK; createDealCopyForProject → 11. OVERCOMMIT.
//
// Симуляция: project уже at-limit (delivered_today=1, daily_limit_target=1) к
// моменту createDealCopyForProject — мокнутый LeadRouter возвращает его как eligible
// (так, будто matchEligibleProjects делал SELECT когда delivered_today=0).
//
// Fix #2: внутри createDealCopyForProject под lockForUpdate(Project) — recheck
// delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
// Если уже at-limit → return false без charge / counter / deal-row.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'race-recheck.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'race-recheck.ru',
'is_active' => true,
'daily_limit_target' => 1,
'effective_daily_limit_today' => null, // COALESCE → daily_limit_target=1
'delivered_today' => 1, // ALREADY AT LIMIT (race-window simulation)
'delivered_in_month' => 5,
]);
$vid = 8888;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_race-recheck.ru',
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
// Подсунуть LeadRouter mock, который игнорирует filter и возвращает project,
// как будто SELECT'нул его при snapshot delivered_today=0.
$routerMock = M::mock(LeadRouter::class);
$routerMock->shouldReceive('matchEligibleProjects')
->andReturn(new Collection([$project]));
app()->instance(LeadRouter::class, $routerMock);
runRouteJob($lead->id);
$lead->refresh();
// После fix #2: deal НЕ создан (recheck под lock увидел limit) → 0 deals.
expect($lead->deals_created_count)->toBe(0);
// delivered_today остался 1 (НЕ инкрементнулся до 2).
expect($project->fresh()->delivered_today)->toBe(1);
// delivered_in_month НЕ инкрементнулся.
expect($project->fresh()->delivered_in_month)->toBe(5);
// balance_rub НЕ списан.
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
// Deal-row не создался.
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
});
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
// seeded distributor — детерминизм
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(
new Randomizer(new Mt19937(7))
));
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
foreach (range(1, 5) as $i) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $t->id, 'is_active' => true,
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]);
linkProjectToSupplier($p, $sp);
createRoutingSnapshotFromProject($p);
}
$lead = SupplierLead::factory()->create([
'phone' => '79991234567',
'vid' => 555111,
'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
'processed_at' => null,
'supplier_project_id' => null,
'platform' => 'B1',
]);
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
$deals = Deal::query()->where('source_crm_id', 555111)->get();
expect($deals)->toHaveCount(3)
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
});
it('merges webhook into csv-recovered deal even when received_at differs (Phase 2 FK fix)', function (): void {
// Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с
// SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at.
// Причина — lead_charges имеет FK на (deal_id, deal_received_at) с
// ON DELETE CASCADE, но ON UPDATE NO ACTION (default). Даже DEFERRABLE INITIALLY
// DEFERRED не помогает — проверка падает на COMMIT. Фикс: оставить received_at
// CSV-recovered deal'а нетронутым (отличие на минуты несущественно).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'phase2-merge.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'phase2-merge.ru',
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
// CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
$csvReceivedAt = now()->subMinutes(15);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$csvDeal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => null,
'project_id' => $project->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => $csvReceivedAt,
]);
// LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'deal_id' => $csvDeal->id,
'deal_received_at' => $csvDeal->received_at,
'charge_source' => 'rub',
]);
// Webhook lead: реальный vid, тот же phone+project, received_at позже CSV.
$webhookVid = 999111;
$webhookReceivedAt = now(); // > csvReceivedAt → старый код триггерил UPDATE received_at.
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $webhookVid,
'phone' => '79991234567',
'received_at' => $webhookReceivedAt,
'raw_payload' => [
'vid' => $webhookVid,
'project' => 'B1_phase2-merge.ru',
'phone' => '79991234567',
'phones' => ['79991234567'],
'time' => $webhookReceivedAt->getTimestamp(),
],
]);
// Не должно бросать FK violation — merge обновляет ТОЛЬКО source_crm_id.
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
// Deal обновлён: source_crm_id заполнен webhook vid, received_at не тронут.
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$merged = Deal::query()
->whereKey($csvDeal->id)
->where('received_at', $csvReceivedAt)
->first();
expect($merged)->not->toBeNull();
expect($merged->source_crm_id)->toBe($webhookVid);
// Без второго списания — balance не изменился (chargeForDelivery в merge-ветке не вызывается).
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
// supplier_lead_deliveries — линк создан.
$deliveryCount = DB::table('supplier_lead_deliveries')
->where('supplier_lead_id', $lead->id)
->where('tenant_id', $tenant->id)
->count();
expect($deliveryCount)->toBe(1);
// Никаких дублей deals — только один с этим vid.
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
});
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
runRouteJob($lead->id);
// deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBe('Москва');
});
it('leaves deal city null when region is unknown', function (): void {
config(['services.dadata.enabled' => false]);
// Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
runRouteJob($lead->id);
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBeNull();
});