605c457c49
Code-review Important: один сбой Project не должен абортить routing для остальных tenant'ов (sharing-model). + try/catch + Log::warning + RuntimeException только если ВСЕ projects упали. + 2 новых теста: mixed routing (1 dup из 3 + 2 clean) и partial failure (soft-delete tenant в середине loop'а). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
11 KiB
PHP
320 lines
11 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\DuplicateDetector;
|
||
use App\Services\LeadRouter;
|
||
use App\Services\NotificationService;
|
||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function (): void {
|
||
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(DuplicateDetector::class),
|
||
app(NotificationService::class),
|
||
);
|
||
}
|
||
|
||
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_leads' => 100]);
|
||
$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,
|
||
]));
|
||
}
|
||
|
||
$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('decrements balance_leads for each tenant by 1', function (): void {
|
||
$supplier = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'test.ru',
|
||
]);
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'supplier_b1_project_id' => $supplier->id,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'test.ru',
|
||
'is_active' => true,
|
||
]);
|
||
|
||
$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($tenant->fresh()->balance_leads)->toBe(99);
|
||
});
|
||
|
||
it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
|
||
$supplier = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'test.ru',
|
||
]);
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'supplier_b1_project_id' => $supplier->id,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'test.ru',
|
||
'is_active' => true,
|
||
'delivered_today' => 0,
|
||
]);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||
$master = Deal::create([
|
||
'tenant_id' => $tenant->id,
|
||
'source_crm_id' => 999,
|
||
'project_id' => $project->id,
|
||
'phone' => '79991234567',
|
||
'phones' => ['79991234567'],
|
||
'status' => 'new',
|
||
'received_at' => now()->subHours(2),
|
||
]);
|
||
|
||
$vid = 1000;
|
||
$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($tenant->fresh()->balance_leads)->toBe(100);
|
||
expect($project->fresh()->delivered_today)->toBe(0);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||
$duplicate = Deal::where('source_crm_id', $vid)->first();
|
||
expect($duplicate)->not->toBeNull();
|
||
expect($duplicate->duplicate_of_id)->toBe($master->id);
|
||
});
|
||
|
||
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('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', 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_leads' => 100]);
|
||
$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,
|
||
]));
|
||
}
|
||
|
||
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
|
||
$masterTenant = $tenants[0];
|
||
$masterProject = $projects[0];
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
|
||
$master = Deal::create([
|
||
'tenant_id' => $masterTenant->id,
|
||
'source_crm_id' => 555,
|
||
'project_id' => $masterProject->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();
|
||
expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается
|
||
|
||
// Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0
|
||
expect($masterTenant->fresh()->balance_leads)->toBe(100);
|
||
expect($masterProject->fresh()->delivered_today)->toBe(0);
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
|
||
$dupDeal = Deal::query()->where('source_crm_id', $vid)->first();
|
||
expect($dupDeal->duplicate_of_id)->toBe($master->id);
|
||
|
||
// Tenant #1, #2: balance списан, delivered_today инкрементирован
|
||
foreach ([1, 2] as $i) {
|
||
$t = $tenants[$i];
|
||
$p = $projects[$i];
|
||
expect($t->fresh()->balance_leads)->toBe(99);
|
||
expect($p->fresh()->delivered_today)->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_leads' => 100]);
|
||
$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,
|
||
]));
|
||
}
|
||
|
||
// 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($tenants[0]->fresh()->balance_leads)->toBe(99);
|
||
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
|
||
});
|