Files
portal/app/tests/Feature/Autopodbor/AutopodborDuplicateGroupsApiTest.php
T
Дмитрий 70ca25762a feat(autopodbor): гейт «сайт-якорь» для разворота + кросс-ящик дублей + счётчик на кнопке
1. Разворот по телефонам запускается ТОЛЬКО при наличии своего сайта (seedDomain).
   Без сайта имя размыто (напр. «Южный берег» → автосервисы/гостиницы), сеедные
   телефоны грязные, а разворот их лишь умножает. Доказано вживую: ДСК/СМ-сити
   (сайт есть) — чисто; «Южный берег» (нет) — 70% мусора.

2. Поиск дублей научился кросс-ящик поле↔предложения (scope=cross): предложение-
   дубль карточки поля теперь всплывает (merge кросс-ящик уже умеет). В выдаче
   виден box каждой фирмы. Без scope — прежнее поведение (один ящик).

3. На кнопке «Найти и объединить дубли» показывается число найденных групп-дублей
   (считается на загрузке, обновляется после слияния/«это разные»).

Юнит 267/267, feature 153/153, фронт 15/15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:01:34 +03:00

234 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function dupSetup(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
return [$tenant, $user, $run];
}
function dupComp(Tenant $t, AutopodborRun $run, string $name): AutopodborCompetitor
{
return AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id,
'name' => $name, 'dedup_key' => 'c:'.md5($name), 'box' => 'field',
]);
}
function dupCall(Tenant $t, AutopodborRun $run, AutopodborCompetitor $c, string $phone): void
{
AutopodborSource::create([
'tenant_id' => $t->id, 'competitor_id' => $c->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => $phone, 'dedup_key' => 'call:'.$phone,
'box' => 'field', 'confirmations' => 1,
]);
}
it('GET field/duplicate-groups — группирует конкурентов с общим телефоном, отдаёт имена и причину', function () {
[$t, $u, $run] = dupSetup();
$a = dupComp($t, $run, 'AutoMoney');
$b = dupComp($t, $run, 'Автодом24');
$c = dupComp($t, $run, 'Другая'); // без общих контактов
dupCall($t, $run, $a, '79135396546');
dupCall($t, $run, $b, '79135396546');
dupCall($t, $run, $c, '79990001122');
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')->assertOk();
$resp->assertJsonCount(1, 'groups');
expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('телефон');
$ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all();
expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all());
$names = collect($resp->json('groups.0.competitors'))->pluck('name')->all();
expect($names)->toContain('AutoMoney')->toContain('Автодом24');
});
it('GET field/duplicate-groups?box=proposal — группирует ТОЛЬКО предложения, поле не мешает', function () {
[$t, $u, $run] = dupSetup();
// Два дубля в предложениях
$p1 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл A', 'dedup_key' => 'p:a', 'box' => 'proposal']);
$p2 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл B', 'dedup_key' => 'p:b', 'box' => 'proposal']);
dupCall($t, $run, $p1, '79135396546');
dupCall($t, $run, $p2, '79135396546');
// Дубль в поле — не должен попасть в выборку по box=proposal
$f1 = dupComp($t, $run, 'Поле A');
$f2 = dupComp($t, $run, 'Поле B');
dupCall($t, $run, $f1, '79990001122');
dupCall($t, $run, $f2, '79990001122');
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk();
$resp->assertJsonCount(1, 'groups');
$ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all();
expect($ids)->toBe(collect([$p1->id, $p2->id])->sort()->values()->all());
});
function dupProp(Tenant $t, AutopodborRun $run, string $name, array $phones): AutopodborCompetitor
{
return AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id,
'name' => $name, 'dedup_key' => 'p:'.md5($name), 'box' => 'proposal', 'phones' => $phones,
]);
}
it('предложения: общий телефон из сохранённого поля (БЕЗ источников) ловится, причина — тот номер', function () {
// Шаг 3: у предложений источников нет — телефоны берём из поля phones конкурента.
[$t, $u, $run] = dupSetup();
$a = dupProp($t, $run, 'AutoMoney', ['79135396546']);
$b = dupProp($t, $run, 'Автодом24', ['79135396546', '73912000000']);
dupProp($t, $run, 'Другая', ['79990001122']); // свой номер — не в группе
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk();
$resp->assertJsonCount(1, 'groups');
// причина = именно СВЯЗАВШИЙ номер, а не весь список телефонов
expect($resp->json('groups.0.reason'))->toContain('79135396546')
->and($resp->json('groups.0.reason'))->not->toContain('73912000000');
$ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all();
expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all());
});
it('группа несёт сайт и ссылки-справочники каждой фирмы — чтобы клиент мог проверить и решить', function () {
[$t, $u, $run] = dupSetup();
$a = AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Сфера займа', 'dedup_key' => 'p:sf',
'box' => 'proposal', 'phones' => ['73912329282'],
'site_url' => 'sfera.ru', 'directory_urls' => ['https://2gis.ru/krasnoyarsk/firm/1'],
]);
$b = AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Ломбард Экспресс', 'dedup_key' => 'p:le',
'box' => 'proposal', 'phones' => ['73912329282'],
'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/org/le/2'],
]);
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk();
$comp = collect($resp->json('groups.0.competitors'))->keyBy('name');
expect($comp['Сфера займа']['site_url'])->toBe('sfera.ru')
->and($comp['Сфера займа']['directory_urls'])->toContain('https://2gis.ru/krasnoyarsk/firm/1')
->and($comp['Ломбард Экспресс']['site_url'])->toBeNull()
->and($comp['Ломбард Экспресс']['directory_urls'])->toContain('https://yandex.ru/maps/org/le/2');
});
it('предложения: рекламный номер у слишком многих (>4) НЕ группирует (порог людного номера)', function () {
[$t, $u, $run] = dupSetup();
for ($i = 1; $i <= 5; $i++) {
dupProp($t, $run, 'Фирма '.$i, ['79135396546']); // один рекламный номер у пятерых
}
$this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')
->assertOk()
->assertJsonCount(0, 'groups');
});
it('GET field/duplicate-groups — группирует по общему адресу источников (слабый сигнал)', function () {
[$t, $u, $run] = dupSetup();
$a = dupComp($t, $run, 'ГорЗайм');
$b = dupComp($t, $run, 'Центр Займов');
// Разные телефоны, но ОДИН адрес → должно предложить объединение по адресу.
foreach ([[$a, '79001112233'], [$b, '79004445566']] as [$comp, $phone]) {
AutopodborSource::create([
'tenant_id' => $t->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => $phone, 'dedup_key' => 'call:'.$phone,
'box' => 'field', 'confirmations' => 1, 'office' => 'Водопьянова 2',
]);
}
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')->assertOk();
$resp->assertJsonCount(1, 'groups');
expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('адрес');
$ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all();
expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all());
});
it('GET field/duplicate-groups — дублей нет → пустой список групп', function () {
[$t, $u, $run] = dupSetup();
$a = dupComp($t, $run, 'A');
$b = dupComp($t, $run, 'B');
dupCall($t, $run, $a, '79135396546');
dupCall($t, $run, $b, '79990001122');
$this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')
->assertOk()
->assertJsonCount(0, 'groups');
});
it('GET field/duplicate-groups?scope=cross — ловит предложение как дубль карточки поля', function () {
[$t, $u, $run] = dupSetup();
$f = dupComp($t, $run, 'СМ-сити'); // в поле
$p = AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Южный берег',
'dedup_key' => 'p:yb', 'box' => 'proposal',
]);
dupCall($t, $run, $f, '73912179941');
dupCall($t, $run, $p, '73912179941'); // общий телефон — связывает предложение с полем
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross')->assertOk();
$resp->assertJsonCount(1, 'groups');
$comp = collect($resp->json('groups.0.competitors'))->keyBy('id');
expect($comp->keys()->map(fn ($k) => (int) $k)->sort()->values()->all())
->toBe(collect([$f->id, $p->id])->sort()->values()->all());
// в выдаче видно, кто из какого ящика (чтобы клиент понял: это предложение к карточке поля)
expect($comp[$p->id]['box'])->toBe('proposal')
->and($comp[$f->id]['box'])->toBe('field');
});
it('GET field/duplicate-groups БЕЗ scope — по-прежнему один ящик (поле), предложения не мешают', function () {
[$t, $u, $run] = dupSetup();
$f = dupComp($t, $run, 'Поле A');
$p = AutopodborCompetitor::create([
'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл A',
'dedup_key' => 'p:a', 'box' => 'proposal',
]);
dupCall($t, $run, $f, '73912179941');
dupCall($t, $run, $p, '73912179941');
// без scope=cross поле и предложение НЕ группируются (обратная совместимость)
$this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')
->assertOk()
->assertJsonCount(0, 'groups');
});
it('POST field/merge — сливает подтверждённую группу, возвращает выжившего, дубль исчезает', function () {
[$t, $u, $run] = dupSetup();
$a = dupComp($t, $run, 'A');
$b = dupComp($t, $run, 'B');
dupCall($t, $run, $a, '79135396546');
dupCall($t, $run, $b, '79135396546');
$resp = $this->actingAs($u)->postJson('/api/autopodbor/field/merge', [
'competitor_ids' => [$a->id, $b->id],
])->assertOk();
$survivor = $resp->json('survivor_id');
expect(AutopodborCompetitor::whereIn('id', [$a->id, $b->id])->count())->toBe(1);
expect($survivor)->toBeIn([$a->id, $b->id]);
});
it('POST field/merge — меньше двух id → 422', function () {
[$t, $u, $run] = dupSetup();
$a = dupComp($t, $run, 'A');
$this->actingAs($u)->postJson('/api/autopodbor/field/merge', [
'competitor_ids' => [$a->id],
])->assertStatus(422);
});