Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
T

230 lines
10 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->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 → регион не тронут
});