feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag

This commit is contained in:
Дмитрий
2026-05-20 11:46:24 +03:00
parent d5cd9209b4
commit 6bfcabddbb
2 changed files with 97 additions and 12 deletions
+13 -10
View File
@@ -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(
@@ -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]);
});