Files
portal/app/tests/Feature/Services/LeadRouterTest.php
T
Дмитрий e401491947 feat(supplier): Plan 4 Task 4 — integrate LedgerService в RouteSupplierLeadJob + Task 3 carry-overs
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>
2026-05-11 10:06:38 +03:00

238 lines
9.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Clear tenant context — LeadRouter operates without it (sharing across tenants).
// Use set_config (session-scoped, rolls back via DatabaseTransactions).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
it('returns matching active projects for B1 site supplier_project (sharing across tenants)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
]);
$tenant1 = Tenant::factory()->create(['balance_leads' => 100]);
$tenant2 = Tenant::factory()->create(['balance_leads' => 100]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
$router = app(LeadRouter::class);
$matched = $router->matchEligibleProjects($supplier, '79991234567');
expect($matched)->toHaveCount(2);
expect($matched->pluck('id')->all())->toEqualCanonicalizing([$project1->id, $project2->id]);
});
it('skips paused project (is_active=false)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$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' => 'example.com',
'is_active' => false,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});
it('skips project where today is not in delivery_days_mask', function (): void {
// Mirror LeadRouter's МСК alignment to avoid off-by-one near midnight when
// process TZ (UTC) and Europe/Moscow disagree on ISO day-of-week.
$todayBit = 1 << (now('Europe/Moscow')->isoWeekday() - 1);
$maskWithoutToday = 127 & ~$todayBit;
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$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' => 'example.com',
'is_active' => true,
'delivery_days_mask' => $maskWithoutToday,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});
it('skips project where delivered_today >= effective_daily_limit_today', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$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' => 'example.com',
'is_active' => true,
'effective_daily_limit_today' => 5,
'delivered_today' => 5,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});
it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$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' => 'example.com',
'is_active' => true,
'effective_daily_limit_today' => null,
'daily_limit_target' => 10,
'delivered_today' => 5,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});
it('skips project where region_mode=include and region_mask does not include phone district', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$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' => 'example.com',
'is_active' => true,
'region_mask' => 1, // только Центральный округ
'region_mode' => 'include',
]);
$router = app(LeadRouter::class);
// 78121234567 = СПб (Северо-Западный, бит 2)
expect($router->matchEligibleProjects($supplier, '78121234567'))->toHaveCount(0);
});
it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (Plan 4 dual-balance)', function (): void {
// Plan 4 Task 4: фильтр расширен на (balance_leads > 0 OR balance_rub > 0).
// Tenant с обоими нулями — реально невыдачный, должен скипаться.
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '0.00']);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});
it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balance rub-only tenant)', function (): void {
// Plan 4 Task 4: rub-only tenant ДОЛЖЕН пройти LeadRouter; LedgerService
// сам резолвит prepaid/rub в transaction'е.
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
expect($eligible)->toHaveCount(1);
expect($eligible->first()->id)->toBe($project->id);
});
it('routes through correct FK based on platform (B2 → supplier_b2_project_id)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => null,
'supplier_b2_project_id' => $supplier->id,
'supplier_b3_project_id' => null,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});
it('orders results by created_at ASC (deterministic, spec §6 step 4)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$projectsCreated = collect();
for ($i = 0; $i < 3; $i++) {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$projectsCreated->push(
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'created_at' => now()->subDays(3 - $i),
])
);
}
$router = app(LeadRouter::class);
$matched = $router->matchEligibleProjects($supplier, '79991234567');
expect($matched->pluck('id')->all())->toBe($projectsCreated->pluck('id')->all());
});