seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); config([ 'services.dadata.enabled' => true, 'services.dadata.api_key' => 'k', 'services.dadata.secret' => 's', 'services.dadata.daily_cap_rub' => 100000, ]); }); function runRegionJob(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), ); } /** * Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot. * * @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject} */ function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key, ]); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => $key, 'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0, 'daily_limit_target' => 100, ]); linkProjectToSupplier($project, $supplier); createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions); $vid = 432176649; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => $phone, 'received_at' => now(), 'raw_payload' => [ 'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag, 'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(), ], ]); return [$lead, $project, $tenant, $supplier]; } function dealFor(int $tenantId, int $projectId): ?Deal { DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'"); $deal = Deal::query()->where('project_id', $projectId)->first(); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); return $deal; } it('lead with phone uses dadata region, not the tag', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([[ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67', ]], 200)]); // tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82). [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567'); runRegionJob($lead->id); $lead->refresh(); expect($lead->resolved_subject_code)->toBe(82) ->and($lead->region_source)->toBe('dadata') ->and($lead->phone_operator)->toBe('МТС'); $deal = dealFor($tenant->id, $project->id); expect($deal)->not->toBeNull() ->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83) ->and((bool) $deal->region_substituted)->toBeFalse() ->and($deal->phone_operator)->toBe('МТС'); }); it('logs exactly one region resolution row per lead', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([[ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', ]], 200)]); [$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567'); runRegionJob($lead->id); $rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get(); expect($rows)->toHaveCount(1); expect($rows->first()->region_source)->toBe('dadata'); // Телефон в логе маскирован (не сырой номер) — §7.1. expect($rows->first()->phone_masked)->not->toBe('79161234567'); }); it('lead with invalid phone falls back to tag', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); // Невалидный телефон → DaData не дёргается → tag (Москва=82). [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123'); runRegionJob($lead->id); $lead->refresh(); expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82); Http::assertNothingSent(); }); it('lead with resolver disabled via flag uses tag', function (): void { config(['services.dadata.enabled' => false]); Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567'); runRegionJob($lead->id); $lead->refresh(); expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82); Http::assertNothingSent(); }); it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]); [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567'); // Эмулируем предыдущий try: резолв уже персистнут. $lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']); runRegionJob($lead->id); Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData $lead->refresh(); expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz'); }); it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([[ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', ]], 200)]); // Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3. [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567'); runRegionJob($lead->id); $deal = dealFor($tenant->id, $project->id); expect($deal)->not->toBeNull() ->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер) ->and((bool) $deal->region_substituted)->toBeTrue(); // Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code. $log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first(); expect((int) $log->actual_subject_code)->toBe(82) ->and((int) $log->substituted_subject_code)->toBe(83); }); it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void { Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]); [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567'); // CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО). DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $csvDeal = Deal::create([ 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id, 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new', 'received_at' => now(), 'subject_code' => 53, ]); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); runRegionJob($lead->id); $merged = dealFor($tenant->id, $project->id); expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая ->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53) ->and($merged->phone_operator)->toBe('МТС') ->and((int) $merged->source_crm_id)->toBe($lead->vid); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); }); it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void { config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag) [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567'); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); Deal::create([ 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id, 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new', 'received_at' => now(), 'subject_code' => 53, ]); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); runRegionJob($lead->id); $merged = dealFor($tenant->id, $project->id); expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут });