handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); } it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void { $cols = collect(DB::select( "SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'" ))->pluck('column_name')->all(); expect($cols)->toContain('supplier_lead_id') ->toContain('tenant_id') ->toContain('deal_id') ->toContain('created_at'); $pk = collect(DB::select( "SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary" ))->pluck('attname')->sort()->values()->all(); expect($pk)->toBe(['supplier_lead_id', 'tenant_id']); $rls = DB::selectOne( "SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'" ); expect($rls->relrowsecurity)->toBeTrue(); }); it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge (max-remaining-limit tie-break)', function (): void { $this->seed(PricingTierSeeder::class); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru', ]); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); // Two eligible projects for the SAME tenant, different remaining limit. $pLow = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'supplier_b1_project_id' => $sp->id, 'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru', 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); $pHigh = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'supplier_b1_project_id' => $sp->id, 'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru', 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($pLow, $sp); linkProjectToSupplier($pHigh, $sp); // LeadRouter выбирает кандидатов только из project_routing_snapshots (slepok-инвариант). createRoutingSnapshotFromProject($pLow, signalType: 'site', signalIdentifier: 'twoproj.ru'); createRoutingSnapshotFromProject($pHigh, signalType: 'site', signalIdentifier: 'twoproj.ru'); $vid = 600001; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJobB($lead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1); // The project with most remaining limit was chosen. expect($pHigh->fresh()->delivered_today)->toBe(1); expect($pLow->fresh()->delivered_today)->toBe(9); }); it('lock: re-running same delivery to same tenant does not double-charge (Spec B)', function (): void { $this->seed(PricingTierSeeder::class); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru', ]); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); $p = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'supplier_b1_project_id' => $sp->id, 'signal_type' => 'site', 'signal_identifier' => 'lock.ru', 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'lock.ru'); $vid = 610001; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJobB($lead->id); // Reset processed_at to force a SECOND pass (bypass the existing $lead->processed_at idempotency // guard so we are testing the DB-level lock specifically). $lead->update(['processed_at' => null]); runRouteJobB($lead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1); expect(DB::table('supplier_lead_deliveries') ->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1); // Balance debited exactly once. expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); }); it('same phone, two different deliveries to one tenant → both charged (no phone dedup, Spec B)', function (): void { $this->seed(PricingTierSeeder::class); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru', ]); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); $p = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'supplier_b1_project_id' => $sp->id, 'signal_type' => 'site', 'signal_identifier' => 'twohit.ru', 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'twohit.ru'); foreach ([700001, 700002] as $vid) { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJobB($lead->id); } DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2); expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2); expect((string) $tenant->fresh()->balance_rub)->toBe('99000.00'); }); it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)', function (): void { $this->seed(PricingTierSeeder::class); app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7)))); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru', ]); foreach (range(1, 5) as $i) { $t = Tenant::factory()->create(['balance_rub' => '100000.00']); $p = Project::factory()->create([ 'tenant_id' => $t->id, 'is_active' => true, 'supplier_b1_project_id' => $sp->id, 'signal_type' => 'site', 'signal_identifier' => 'cap3.ru', 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'cap3.ru'); } $vid = 710001; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJobB($lead->id); $lead->refresh(); expect($lead->deals_created_count)->toBe(3); expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3); expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3); });