Files
portal/app/tests/Feature/Autopodbor/AutopodborDuplicateGroupsApiTest.php
T
Дмитрий 7d6516e6b2 feat(autopodbor): журнал слияний + возврат поглощённой карточки
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
autopodbor_merge_events со СНИМКОМ поглощённых карточек (имя/сайт/телефоны/
справочники), кто и когда слил.

- Таблица autopodbor_merge_events (+RLS tenant_isolation, паттерн 1:1 с sources,
  rls-ревью OK; nullOnDelete на survivor_id/user_id — история не рушится).
- AutopodborCompetitorMerger::merge($tenantId,$ids,$name,$userId) пишет событие.
- GET /field/merge-events — история; POST /field/merge-events/{id}/restore —
  возврат поглощённых как предложения (защита от повторного возврата).
- Фронт: панель «🕘 История слияний» + «↩ Вернуть карточку».

Бэк 428/428, фронт 12/12, Pint чистый, build ок. schema.sql канон-синк всех
таблиц автоподбора — предвыкатный TODO (вся фича migration-only в этой ветке).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:52:43 +03:00

297 lines
17 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('ответ помечает ВЫЖИВАЮЩУЮ карточку — и это НЕ обязательно первая по номеру', function () {
// Баг «САПС/Метрополис»: клиент переименовал свежую карточку и слил — а выжила ДРУГАЯ (с проектами
// /в поле), его имя затёрлось. Чтобы этого не было вслепую, ответ помечает, какая карточка останется,
// а фронт по ней ставит имя по умолчанию и показывает «останется эта».
[$t, $u, $run] = dupSetup();
$prop = dupProp($t, $run, 'Метрополис', ['73912555555']); // свежее предложение, меньший id, без источников
$field = dupComp($t, $run, 'СМ-сити'); // полевая, больший id, с источником в поле
dupCall($t, $run, $field, '73912555555'); // общий телефон → одна группа
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross')->assertOk();
$comp = collect($resp->json('groups.0.competitors'))->keyBy('id');
expect($comp[$field->id]['survivor'])->toBeTrue() // выживает полевая (в ней работа), хоть id больше
->and($comp[$prop->id]['survivor'])->toBeFalse();
// ровно одна выжившая на группу
expect(collect($resp->json('groups.0.competitors'))->where('survivor', true))->toHaveCount(1);
});
it('предложения: единый номер застройщика у многих проектов ГРУППИРУЕТ (порог людного номера снят)', function () {
// Кейс «Образцово» (реш. владельца 05.07): у застройщика (СМ.Сити) один номер отдела продаж
// висит на всех его ЖК. Прежний порог 4 прятал их как «рекламный номер». Теперь показываем
// группу — склейку клиент подтверждает вручную.
[$t, $u, $run] = dupSetup();
for ($i = 1; $i <= 9; $i++) {
dupProp($t, $run, 'ЖК '.$i, ['73912779901']); // единый номер застройщика на 9 проектах
}
$resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')
->assertOk()
->assertJsonCount(1, 'groups');
expect($resp->json('groups.0.competitors'))->toHaveCount(9);
expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('телефон');
});
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('scope=cross — пара чисто-предложений (без карточки поля) НЕ показывается на экране поля', 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, '73912179941');
dupCall($t, $run, $p2, '73912179941');
$this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross')
->assertOk()
->assertJsonCount(0, 'groups');
});
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);
});
it('merge-events: после склейки в журнале видно «что во что слили», и карточку можно ВЕРНУТЬ', function () {
[$t, $u, $run] = dupSetup();
$keep = dupComp($t, $run, 'СМ-сити'); // выживет (источник в поле)
$gone = dupProp($t, $run, 'Метрополис', ['79135396546']); // поглотится
dupCall($t, $run, $keep, '79135396546');
// Сливаем (кросс-ящик поле+предложение)
$this->actingAs($u)->postJson('/api/autopodbor/field/merge', [
'competitor_ids' => [$keep->id, $gone->id],
])->assertOk();
// Журнал показывает слияние: поглощена «Метрополис», выжившая — «СМ-сити»
$events = $this->actingAs($u)->getJson('/api/autopodbor/field/merge-events')->assertOk();
$events->assertJsonCount(1, 'events');
expect($events->json('events.0.survivor_name'))->toBe('СМ-сити');
expect(collect($events->json('events.0.absorbed'))->pluck('name')->all())->toContain('Метрополис');
$eventId = $events->json('events.0.id');
// Возврат: «Метрополис» появляется снова как отдельная карточка-предложение
$restore = $this->actingAs($u)->postJson("/api/autopodbor/field/merge-events/{$eventId}/restore")->assertOk();
expect(collect($restore->json('restored'))->pluck('name')->all())->toContain('Метрополис');
expect(AutopodborCompetitor::where('tenant_id', $t->id)->where('name', 'Метрополис')->where('box', 'proposal')->exists())->toBeTrue();
// Повторный возврат из того же события — 422 (уже вернули)
$this->actingAs($u)->postJson("/api/autopodbor/field/merge-events/{$eventId}/restore")->assertStatus(422);
});