230 lines
10 KiB
PHP
230 lines
10 KiB
PHP
<?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 → регион не тронут
|
||
});
|