From 6bfcabddbb5d4acc7e0f4e45df0af915bc41a98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 20 May 2026 11:46:24 +0300 Subject: [PATCH] feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag --- app/app/Jobs/RouteSupplierLeadJob.php | 23 ++--- .../Feature/Jobs/RouteSupplierLeadJobTest.php | 86 ++++++++++++++++++- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index ddeaa212..917222b7 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -12,8 +12,10 @@ use App\Models\SupplierLead; use App\Models\Tenant; use App\Services\Billing\LedgerService; use App\Services\DuplicateDetector; +use App\Services\LeadDistributor; use App\Services\LeadRouter; use App\Services\NotificationService; +use App\Services\RegionTagResolver; use App\Services\SupplierProjects\SupplierProjectResolver; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -86,6 +88,8 @@ class RouteSupplierLeadJob implements ShouldQueue DuplicateDetector $duplicateDetector, NotificationService $notifier, LedgerService $ledger, + LeadDistributor $distributor, + RegionTagResolver $tagResolver, ): void { $lead = SupplierLead::findOrFail($this->supplierLeadId); @@ -109,19 +113,18 @@ class RouteSupplierLeadJob implements ShouldQueue $lead->update(['supplier_project_id' => $supplier->id]); $matched = $router->matchEligibleProjects($supplier); + $selected = $distributor->selectRecipients($matched); // cap=3 случайных + + $subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? '')); $createdCount = 0; $failures = []; - foreach ($matched as $project) { + foreach ($selected as $project) { try { - if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) { + if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) { $createdCount++; } } catch (Throwable $e) { - // Per-Project failure isolation (Plan 2 code-review Important). - // Sharing-model: один сбой проекта не должен абортить routing других tenant'ов. - // Логируем и продолжаем; final failed() callback зафиксирует общий проблемный лид - // только если ВСЕ Projects упали (через handle() rethrow ниже). $failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()]; Log::warning('supplier_lead.per_project_routing_failed', [ 'supplier_lead_id' => $lead->id, @@ -132,9 +135,7 @@ class RouteSupplierLeadJob implements ShouldQueue } } - // Если ВСЕ Projects упали (а matched был непустой) — пробрасываем последнюю ошибку, - // чтобы failed() callback сработал и проблема ушла в failed_webhook_jobs. - if ($matched->isNotEmpty() && $createdCount === 0 && count($failures) === $matched->count()) { + if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) { throw new RuntimeException( 'All eligible projects failed routing for supplier_lead='.$lead->id. '; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown') @@ -199,9 +200,10 @@ class RouteSupplierLeadJob implements ShouldQueue DuplicateDetector $duplicateDetector, NotificationService $notifier, LedgerService $ledger, + ?int $subjectCode, ): bool { try { - return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool { + return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool { DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'"); /** @var Tenant $tenant */ @@ -252,6 +254,7 @@ class RouteSupplierLeadJob implements ShouldQueue 'phones' => $phones, 'status' => 'new', 'received_at' => $receivedAt, + 'subject_code' => $subjectCode, ]); $master = $duplicateDetector->findMaster( diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index 444d39c6..baa4131f 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -10,14 +10,18 @@ use App\Models\SupplierProject; use App\Models\Tenant; use App\Services\Billing\LedgerService; use App\Services\DuplicateDetector; +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); @@ -37,9 +41,33 @@ function runRouteJob(int $supplierLeadId): void app(DuplicateDetector::class), app(NotificationService::class), app(LedgerService::class), + app(LeadDistributor::class), + app(RegionTagResolver::class), ); } +/** + * Link Лидерра-project to supplier_project via the new pivot (Plan 1 model). + * LeadRouter post-Plan-2 eligibility query reads project_supplier_links only — + * legacy supplier_b{1,2,3}_project_id FK is ignored for routing. + * + * Guarded against re-declaration — LeadRouterTest.php declares the same helper + * globally; Pest loads both files in one PHP process, so the second declaration + * would Fatal. Bodies are semantically identical. + */ +if (! function_exists('linkProjectToSupplier')) { + function linkProjectToSupplier(Project $project, SupplierProject $supplier): void + { + DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $supplier->id, + 'platform' => $supplier->platform, + // @phpstan-ignore-next-line property.notFound — subject_code in $fillable/casts, IDE stubs lag + 'subject_code' => $supplier->subject_code, + ]); + } +} + it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', @@ -61,6 +89,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct 'delivered_today' => 0, 'delivered_in_month' => 0, ])); + linkProjectToSupplier($projects->last(), $supplier); } $vid = 432176649; @@ -108,13 +137,14 @@ it('decrements balance_leads for each tenant by 1', function (): void { 'unique_key' => 'test.ru', ]); $tenant = Tenant::factory()->create(['balance_leads' => 100]); - Project::factory()->create([ + $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([ @@ -145,6 +175,7 @@ it('marks duplicate via DuplicateDetector — no charge, no counter increment', 'is_active' => true, 'delivered_today' => 0, ]); + linkProjectToSupplier($project, $supplier); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $master = Deal::create([ @@ -229,6 +260,7 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean 'delivered_today' => 0, 'delivered_in_month' => 0, ])); + linkProjectToSupplier($projects->last(), $supplier); } // Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль. @@ -299,6 +331,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de 'is_active' => true, 'delivered_today' => 0, ]); + linkProjectToSupplier($project, $supplier); $vid = 7777; $lead = SupplierLead::factory()->create([ @@ -367,6 +400,7 @@ it('handles partial failure: one project throws, others continue routing', funct 'is_active' => true, 'delivered_today' => 0, ])); + linkProjectToSupplier($projects->last(), $supplier); } // Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт. @@ -403,13 +437,14 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car 'unique_key' => $domain, ]); $tenant = Tenant::factory()->create(['balance_leads' => 100]); - Project::factory()->create([ + $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([ @@ -509,3 +544,50 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 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_leads' => 100]); + $p = Project::factory()->create([ + 'tenant_id' => $t->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + DB::table('project_supplier_links')->insert([ + 'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82, + ]); + } + + $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(DuplicateDetector::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]); +});