Files
portal/app/tests/Unit/Autopodbor/AutopodborMergeTest.php
T
Дмитрий df83fa836c feat(автоподбор): Конкурентное поле — финальный проход склейки, вскрыть всё, группы дублей, телефоны, отсев рекламных номеров 2ГИС
Шаг 1 «Конкурентное поле» — чекпоинт:
- Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча.
- Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента.
- Колонка phones jsonb; телефон-дубли видны уже на «Предложениях».
- Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт».
- Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание.
- Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм.

Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят.

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

326 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
function mergeService(): AutopodborDedup
{
return new AutopodborDedup(new AutopodborNormalizer);
}
it('склеивает конкурента, попавшего и по имени, и по домену (одна группа)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Драйв займ'], // только имя (из канала В)
['name' => 'Драйв Займ', 'site_url' => 'https://драйвзайм.рф/'], // имя + домен (из канала А)
]);
// имя «драйвзайм» = корень домена «драйвзайм» → одна карточка
expect($merged)->toHaveCount(1);
expect($merged[0]['site_url'])->toBe('https://драйвзайм.рф/');
});
it('склеивает по общему домену при разном написании имени', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Окна Комфорт', 'site_url' => 'https://okna-komfort.ru/contacts'],
['name' => 'ОКНА-КОМФОРТ', 'site_url' => 'okna-komfort.ru'],
]);
expect($merged)->toHaveCount(1);
});
it('НЕ склеивает по телефону на поиске — телефон не тихий ключ (совпадение решает клиент)', function () {
// Разные фирмы с общим номером (реклама/8-800/общая линия) НЕ должны молча слипаться на поиске:
// машина не отличит «одна сеть» от «чужой рекламный номер» → это на решение клиента (кнопка «Найти дубли»).
$merged = mergeService()->mergeCompetitors([
['name' => 'Фирма А', 'phones' => ['73912920000']],
['name' => 'Фирма Б', 'phones' => ['73912920000', '79991112233']],
]);
expect($merged)->toHaveCount(2); // общий телефон больше не склеивает на поиске
});
it('объединяет ссылки справочников и телефоны внутри группы', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'X', 'site_url' => 'x.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'phones' => ['71112223344']],
['name' => 'X', 'site_url' => 'https://x.ru/', 'directory_urls' => ['https://yandex.ru/maps/org/x/2'], 'phones' => ['75556667788']],
]);
expect($merged)->toHaveCount(1);
expect($merged[0]['directory_urls'])->toContain('https://2gis.ru/firm/1')->toContain('https://yandex.ru/maps/org/x/2');
expect($merged[0]['phones'])->toContain('71112223344')->toContain('75556667788');
});
it('склеивает по общему коду справочника (slug Яндекса), даже при разном написании имени', function () {
// Один и тот же «КрасЛомбард», но имя записано по-разному → по имени НЕ сойдётся.
// Спасает опознавательный код фирмы от Яндекса (slug) — это признак источника, не догадка.
$merged = mergeService()->mergeCompetitors([
['name' => 'КрасЛомбард', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/333']],
['name' => 'Красломбард 24 Взлётка', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/444']],
]);
expect($merged)->toHaveCount(1)
->and($merged[0]['directory_urls'])->toHaveCount(2);
});
it('склеивает «Имя» и «Имя + слово-рубрику» — слово берётся из рубрики фирмы (не из списка)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Яричъ Ломбард', 'description' => 'Ломбард', 'directory_urls' => ['https://yandex.ru/maps/org/yarichl/1']],
['name' => 'Яричъ', 'description' => 'Ломбард', 'directory_urls' => ['https://yandex.ru/maps/org/yarich/2']],
]);
expect($merged)->toHaveCount(1);
});
it('склейка по словарю рубрик: длинное имя БЕЗ своей рубрики тоже цепляется (слово из общего словаря)', function () {
// У «Яричъ Ломбард» своей рубрики нет, но «Ломбард» есть в словаре (его дала рубрика «Яричъ»).
$merged = mergeService()->mergeCompetitors([
['name' => 'Яричъ Ломбард', 'description' => null],
['name' => 'Яричъ', 'description' => 'Ломбард'],
]);
expect($merged)->toHaveCount(1);
});
it('универсально: та же склейка для стоматолога (слово-категория из рубрики, без зашитых ниш)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Улыбка Стоматология', 'description' => 'Стоматология'],
['name' => 'Улыбка', 'description' => 'Стоматология'],
]);
expect($merged)->toHaveCount(1);
});
it('генерик из нескольких слов НЕ склеивается («Ломбард Займы под залог» ≠ «Займы под залог»)', function () {
// После стрижки остаётся описательная фраза «займы под залог» (3 слова) — это генерик, не бренд.
// Третья фирма кладёт «ломбард» в словарь рубрик (как в реальном прогоне).
$merged = mergeService()->mergeCompetitors([
['name' => 'Ломбард Займы под залог', 'description' => null],
['name' => 'Займы под залог', 'description' => 'Микрофинансовая организация'],
['name' => 'Рубин', 'description' => 'Ломбард'],
]);
expect($merged)->toHaveCount(3);
});
it('НЕ склеивает разные имена с одинаковой рубрикой (защита от пере-склейки)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Быстро Ломбард', 'description' => 'Ломбард'],
['name' => 'Дёшево Ломбард', 'description' => 'Ломбард'],
]);
expect($merged)->toHaveCount(2); // «быстро» ≠ «дёшево»
});
it('голое имя-рубрика не плодит пустой ключ (генерики не слипаются по пустоте)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Ломбард', 'description' => 'Ломбард'],
['name' => 'Займ', 'description' => 'Займ'],
]);
expect($merged)->toHaveCount(2);
});
it('не склеивает разных конкурентов', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'],
['name' => 'Пластика Окон', 'site_url' => 'plastika.ru'],
]);
expect($merged)->toHaveCount(2);
});
// ── contactsOnly: финальное решение владельца «домен/любой телефон — да, имя — НЕТ» ──
it('contactsOnly: НЕ склеивает по имени (разные домены, общего телефона нет)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Ломбард', 'site_url' => 'a.ru'],
['name' => 'Ломбард', 'site_url' => 'b.ru'],
], contactsOnly: true);
expect($merged)->toHaveCount(2); // одинаковое имя больше не склеивает
});
it('contactsOnly: склеивает по общему домену (имена разные)', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Первая контора', 'site_url' => 'https://займы-124.рф/contacts'],
['name' => 'Совсем другая', 'site_url' => 'займы-124.рф'],
], contactsOnly: true);
expect($merged)->toHaveCount(1);
});
it('телефон НЕ склеивает даже общий 8-800 (разные сайты) — решает клиент', function () {
// Раньше склеивало по любому общему телефону. Решение 03.07 (баг AutoMoney): рекламный/общий
// номер ложно мостил чужие фирмы → телефон убран из тихой склейки. Совпадение — на решение клиента.
$merged = mergeService()->mergeCompetitors([
['name' => 'Фирма А', 'site_url' => 'a.ru', 'phones' => ['88002000010']],
['name' => 'Фирма Б', 'site_url' => 'b.ru', 'phones' => ['88002000010']],
], contactsOnly: true);
expect($merged)->toHaveCount(2); // общий телефон не склеивает
});
it('contactsOnly: не теряет вариации — все сайты/справочники/телефоны в одной карточке', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'X', 'site_url' => 'x.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'phones' => ['71112223344']],
['name' => 'Y', 'site_url' => 'x.ru', 'directory_urls' => ['https://yandex.ru/maps/org/x/2'], 'phones' => ['75556667788']],
], contactsOnly: true);
expect($merged)->toHaveCount(1);
expect($merged[0]['directory_urls'])->toContain('https://2gis.ru/firm/1')->toContain('https://yandex.ru/maps/org/x/2');
expect($merged[0]['phones'])->toContain('71112223344')->toContain('75556667788');
});
it('дефолт (без contactsOnly) по-прежнему склеивает по имени — параметр реально меняет поведение', function () {
$merged = mergeService()->mergeCompetitors([
['name' => 'Ломбард', 'site_url' => 'a.ru'],
['name' => 'Ломбард', 'site_url' => 'b.ru'],
]);
expect($merged)->toHaveCount(1); // старое поведение сохранено
});
// ── duplicateGroups: группы-кандидаты для ручной склейки «Почистить поле» ──
// Ключи-опознавалки (s:домен / d:справочник / p:телефон) собирает контроллер через
// CompetitorIdentity + телефоны источников; сюда приходят уже готовыми (не плодим параллельную логику).
it('duplicateGroups: группирует по общему ключу-сайту, возвращает id и причину-сайт', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['s:automoney-krsk']],
['id' => 2, 'keys' => ['s:automoney-krsk', 'p:79135396546']],
['id' => 3, 'keys' => ['s:other']],
]);
expect($groups)->toHaveCount(1);
expect($groups[0]['ids'])->toBe([1, 2]);
expect(mb_strtolower($groups[0]['reason']))->toContain('сайт');
});
it('duplicateGroups: цепочка по разным общим телефонам сливается в одну группу', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['p:79001112233']],
['id' => 2, 'keys' => ['p:79001112233', 'p:79004445566']],
['id' => 3, 'keys' => ['p:79004445566']],
]);
expect($groups)->toHaveCount(1);
expect($groups[0]['ids'])->toBe([1, 2, 3]);
});
it('duplicateGroups: причина по телефону содержит номер', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['p:79135396546']],
['id' => 2, 'keys' => ['p:79135396546']],
]);
expect($groups)->toHaveCount(1);
expect($groups[0]['reason'])->toContain('79135396546');
expect(mb_strtolower($groups[0]['reason']))->toContain('телефон');
});
it('duplicateGroups: нет общих ключей — групп нет', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['s:a', 'p:79001112233']],
['id' => 2, 'keys' => ['s:b', 'p:79004445566']],
]);
expect($groups)->toBe([]);
});
it('duplicateGroups: конкурент без ключей ни с кем не группируется', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => []],
['id' => 2, 'keys' => []],
]);
expect($groups)->toBe([]);
});
it('duplicateGroups: адрес — слабый ключ, группирует и даёт причину-адрес', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['a:водопьянова 2']],
['id' => 2, 'keys' => ['a:водопьянова 2']],
['id' => 3, 'keys' => ['a:другой адрес']],
]);
expect($groups)->toHaveCount(1);
expect($groups[0]['ids'])->toBe([1, 2]);
expect(mb_strtolower($groups[0]['reason']))->toContain('адрес');
});
it('duplicateGroups: сайт важнее адреса — причина показывает сайт, если совпал и он', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['s:domain', 'a:ленина 1']],
['id' => 2, 'keys' => ['s:domain', 'a:ленина 1']],
]);
expect($groups)->toHaveCount(1);
expect(mb_strtolower($groups[0]['reason']))->toContain('сайт');
});
it('duplicateGroups: защита от бизнес-центра — адрес с слишком многими фирмами НЕ группирует', function () {
// 6 фирм по одному адресу (порог 4) → это бизнес-центр, склеивать не предлагаем.
$items = [];
for ($i = 1; $i <= 6; $i++) {
$items[] = ['id' => $i, 'keys' => ['a:бизнес центр']];
}
$groups = mergeService()->duplicateGroups($items);
expect($groups)->toBe([]);
});
it('duplicateGroups: рекламный/общий телефон у слишком многих фирм НЕ группирует (как бизнес-центр)', function () {
// Номер «психа» AutoMoney (реклама) или общая 8-800-линия висит у 6 фирм (порог 4) → это НЕ
// «одна фирма», а рекламный/общий номер → такой телефон-ключ выкидываем, ложную группу не строим.
$items = [];
for ($i = 1; $i <= 6; $i++) {
$items[] = ['id' => $i, 'keys' => ['p:79135396546']];
}
$groups = mergeService()->duplicateGroups($items);
expect($groups)->toBe([]);
});
it('duplicateGroups: общий телефон у немногих (2 фирмы) ОСТАЁТСЯ — идёт на решение клиента', function () {
$groups = mergeService()->duplicateGroups([
['id' => 1, 'keys' => ['p:78001234567']],
['id' => 2, 'keys' => ['p:78001234567']],
]);
expect($groups)->toHaveCount(1); // 2 ≤ порога → показываем клиенту, он решит
});
it('duplicateGroups: адрес-бизнес-центр не мешает склейке тех же фирм по сайту', function () {
$items = [
['id' => 1, 'keys' => ['a:бизнес центр', 's:same']],
['id' => 2, 'keys' => ['a:бизнес центр', 's:same']],
['id' => 3, 'keys' => ['a:бизнес центр']],
['id' => 4, 'keys' => ['a:бизнес центр']],
['id' => 5, 'keys' => ['a:бизнес центр']],
];
$groups = mergeService()->duplicateGroups($items);
// адрес выкинут (5 фирм > порога), но 1 и 2 всё равно склеены по сайту
expect($groups)->toHaveCount(1);
expect($groups[0]['ids'])->toBe([1, 2]);
expect(mb_strtolower($groups[0]['reason']))->toContain('сайт');
});
it('вычитает самого клиента (по домену и по имени)', function () {
$merged = mergeService()->mergeCompetitors(
[
['name' => 'Мой Бизнес', 'site_url' => 'moy-biznes.ru'], // это сам клиент по домену
['name' => 'Драйв займ', 'site_url' => 'драйвзайм.рф'],
['name' => 'Мой Бизнес Плюс'], // сам клиент по имени
],
clientKeys: ['https://moy-biznes.ru', 'Мой Бизнес Плюс'],
);
$names = array_column($merged, 'name');
expect($names)->not->toContain('Мой Бизнес');
expect($names)->not->toContain('Мой Бизнес Плюс');
expect($names)->toContain('Драйв займ');
});