seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Новый матч по слепку ВКЛ — тестируем риск §9b именно на нём. DB::table('system_settings')->updateOrInsert( ['key' => 'routing_match_by_snapshot'], ['value' => 'true', 'type' => 'bool', 'updated_at' => now()], ); // sms supplier_project — самая рискованная ветка матча (sms_senders[0]+keyword). $this->sp = SupplierProject::factory()->create([ 'platform' => 'B3', 'signal_type' => 'sms', 'unique_key' => 'Caranga', ]); $this->tenant = Tenant::factory()->create([ 'balance_rub' => '10000.00', 'delivered_in_month' => 0, ]); $this->project = Project::factory()->create([ 'tenant_id' => $this->tenant->id, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['Caranga'], 'sms_keyword' => null, 'supplier_b3_project_id' => $this->sp->id, 'is_active' => true, 'daily_limit_target' => 100, 'effective_daily_limit_today' => 100, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($this->project, $this->sp); // Слепки на сегодня и завтра — несут sms-источник Caranga. insertSmsSnapshotFor($this->project, Carbon::today('Europe/Moscow')->toDateString()); insertSmsSnapshotFor($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString()); }); function insertSmsSnapshotFor(Project $project, string $date): void { DB::table('project_routing_snapshots')->insert([ 'snapshot_date' => $date, 'project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'daily_limit' => 100, 'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => json_encode(['Caranga']), 'sms_keyword' => null, 'expected_volume' => 100, 'delivered_count' => 0, 'created_at' => now(), ]); } function runNoDoubleChargeJob(int $supplierLeadId): void { (new RouteSupplierLeadJob($supplierLeadId))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); } it('не списывает дважды когда CSV-recovery и webhook одного лида приходят вокруг смены источника', function (): void { $phone = '79161234567'; // ── CSV-recovered лид (vid=null) → создаёт Deal ── $csvLead = SupplierLead::factory()->create([ 'platform' => 'B3', 'phone' => $phone, 'vid' => null, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'project' => 'B3_Caranga', 'phone' => $phone, 'time' => now()->subHour()->getTimestamp(), ], 'received_at' => now()->subHour(), 'recovered_from_csv_at' => now()->subHour(), 'source' => 'csv_recovery', 'processed_at' => null, ]); runNoDoubleChargeJob($csvLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); $csvDeal = Deal::where('phone', $phone)->first(); expect($csvDeal)->not->toBeNull('CSV-recovery должен создать Deal под матчем по слепку'); expect(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1); $balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub; // ── webhook того же физического лида (vid=int) приходит позже ── $webhookLead = SupplierLead::factory()->create([ 'platform' => 'B3', 'phone' => $phone, 'vid' => 555, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 555, 'project' => 'B3_Caranga', 'phone' => $phone, 'time' => now()->subMinutes(15)->getTimestamp(), ], 'received_at' => now()->subMinutes(15), 'source' => 'webhook', 'processed_at' => null, ]); runNoDoubleChargeJob($webhookLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); // ── ОДНА сделка, ОДНО списание, баланс не списан дважды ── $deals = Deal::where('phone', $phone)->get(); expect($deals)->toHaveCount(1, '§9b: webhook после CSV merge'.'ится в один deal — project_id сошёлся под матчем по слепку'); expect((int) $deals->first()->source_crm_id)->toBe(555); expect(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1, '§9b: второго списания нет'); expect((string) $this->tenant->fresh()->balance_rub)->toBe($balanceAfterCsv, '§9b: баланс не списан второй раз'); });