seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Новый матч по слепку ВКЛ — иначе хвост по старому источнику теряется (старый pivot-путь). DB::table('system_settings')->updateOrInsert( ['key' => 'routing_match_by_snapshot'], ['value' => 'true', 'type' => 'bool', 'updated_at' => now()], ); }); afterEach(fn () => Carbon::setTestNow()); function flowFixture(): array { $tenant = Tenant::factory()->create(['balance_rub' => '10000.00', 'delivered_in_month' => 0]); $sp = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'sms', 'unique_key' => 'Caranga']); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['Caranga'], 'sms_keyword' => null, 'supplier_b3_project_id' => $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($project, $sp); // Слепок на СЕГОДНЯ с источником Caranga (фиксирует завтрашний заказ = сегодняшнюю раздачу). DB::table('project_routing_snapshots')->insert([ 'snapshot_date' => Carbon::today('Europe/Moscow')->toDateString(), 'project_id' => $project->id, 'tenant_id' => $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(), ]); return ['tenant' => $tenant, 'sp' => $sp, 'project' => $project]; } function routeFlowLead(SupplierProject $sp, string $phone): SupplierLead { $lead = SupplierLead::factory()->create([ 'platform' => 'B3', 'phone' => $phone, 'vid' => 700001, 'supplier_project_id' => $sp->id, 'raw_payload' => ['vid' => 700001, 'project' => 'B3_Caranga', 'phone' => $phone, 'time' => now()->getTimestamp()], 'received_at' => now(), 'source' => 'webhook', 'processed_at' => null, ]); (new RouteSupplierLeadJob($lead->id))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); return $lead; } it('ИЗМЕНЁН источник: лид по СТАРОМУ источнику доезжает до сделки (проект жив)', function (): void { $fix = flowFixture(); // Клиент сменил live-источник Caranga → NewSender (слепок сегодня всё ещё Caranga). DB::table('projects')->where('id', $fix['project']->id)->update(['sms_senders' => json_encode(['NewSender'])]); routeFlowLead($fix['sp'], '79161230001'); DB::statement("SET LOCAL app.current_tenant_id = '{$fix['tenant']->id}'"); expect(Deal::where('project_id', $fix['project']->id)->where('phone', '79161230001')->count()) ->toBe(1, 'хвост по старому источнику должен создать сделку у живого проекта'); }); it('УДАЛЁН проект: лид по его источнику НЕ падает в сироту и не роняет раздачу', function (): void { $fix = flowFixture(); // Жёсткое удаление проекта (как ProjectService::delete после grace) — слепок переживает (нет FK). DB::table('projects')->where('id', $fix['project']->id)->delete(); // Раздача не должна бросить исключение (INNER JOIN отсекает удалённый проект). routeFlowLead($fix['sp'], '79161230002'); // Дошли сюда без исключения = раздача не упала. Сделок нет — лид обработан без сироты. expect(Deal::where('phone', '79161230002')->count())->toBe(0, 'у удалённого проекта сделка не создаётся'); });