e1fdb5ca8e
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
535 lines
21 KiB
PHP
535 lines
21 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use App\Models\Deal;
|
||
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 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('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);
|
||
}
|
||
|
||
$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);
|
||
|
||
$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);
|
||
}
|
||
|
||
// 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);
|
||
|
||
$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);
|
||
}
|
||
|
||
// 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('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);
|
||
|
||
$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'],
|
||
]);
|
||
|
||
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);
|
||
}
|
||
|
||
$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]);
|
||
});
|