feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user