e401491947
Task 4 — integration:
- handle() / createDealCopyForProject() — +5-й параметр LedgerService.
- Заменён старый balance_leads-- + BalanceTransaction блок на
\$ledger->chargeForDelivery(\$tenant, \$deal, \$lead) с try/catch для
InsufficientBalanceException (Log::warning + rethrow; auto-pause flow
в Task 6).
- LeadRouter::matchEligibleProjects — расширен фильтр tenant balance с
(balance_leads > 0) на (balance_leads > 0 OR balance_rub > 0), чтобы
rub-only tenant дошёл до LedgerService (single arbiter for dual-balance).
- 4 E2E теста в tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php:
prepaid charge + BalanceTransaction (carry-over M-2), rub charge + BT,
supplier_lead_costs gap-fix (2 deal-копии), retry idempotency.
Plan 4 Task 3 carry-overs (минорные правки по code-review d2030f9):
- I-2: PHPDoc на LedgerService::chargeForDelivery — @throws + @precondition
(caller wraps в DB::transaction с lockForUpdate Tenant).
- I-4: trim() на raw_payload['project'] в resolveSupplierId (defense
against whitespace).
Прочие правки:
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php — +PricingTierSeeder
в beforeEach + +5-й LedgerService параметр в runRouteJob().
- tests/Feature/Integration/SupplierLeadFlowTest.php — +PricingTierSeeder
в beforeEach (test использует full webhook→job pipeline).
- tests/Feature/Services/LeadRouterTest.php — rename теста про balance_leads
→ \"zero in BOTH balance_leads AND balance_rub\" + новый тест
\"rub-only tenant ДОЛЖЕН пройти\".
- phpstan-baseline.neon — +5 entries для TestCall::seed() + Tenant/LeadCharge
property.notFound в новых файлах (IDE helper @mixin re-generation —
отдельная задача).
Метрики: Pint clean, PHPStan 0 errors, Pest 646/643+3 skipped/0 failed
(21.1s parallel). Plan 4 Task 4 закрыт.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
467 lines
18 KiB
PHP
467 lines
18 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\DuplicateDetector;
|
||
use App\Services\LeadRouter;
|
||
use App\Services\NotificationService;
|
||
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 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(DuplicateDetector::class),
|
||
app(NotificationService::class),
|
||
app(LedgerService::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('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_leads' => 100]);
|
||
$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,
|
||
]);
|
||
|
||
$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($tenant->fresh()->balance_leads)->toBe(99);
|
||
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($tenant->fresh()->balance_leads)->toBe(99);
|
||
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_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);
|
||
});
|
||
|
||
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_leads' => 100]);
|
||
$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_leads НЕ списан.
|
||
expect($tenant->fresh()->balance_leads)->toBe(100);
|
||
|
||
// Deal-row не создался.
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
|
||
});
|