a28058779b
Кейс «Образцово»: у застройщика (СМ.Сити) один номер отдела продаж легитимно висит на всех его ЖК (277-99-01 на 9 проектах). Прежний порог dropCrowdedKeys (>4 карточек → «рекламный», выкинуть) прятал это, и окно «Найти дубли» не показывало проекты застройщика — systematically (6 таких номеров в данных). Решение владельца 05.07: порог по ТЕЛЕФОНУ (p:) убрать — клиент подтверждает склейку вручную («Это разные»), тихой ложной склейки нет; рекламные-подменыши и так отсекаются на сборе. Порог по АДРЕСУ (a:, бизнес-центр) оставлен. Проверено на AutoMoney: его номер у 3 карточек — все реально Автодом24, группа верная, монстр-группы снятие порога не даёт. Тесты переписаны со старого поведения на новое (unit + feature), 421/421. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
18 KiB
PHP
331 lines
18 KiB
PHP
<?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 () {
|
||
// Решение владельца 05.07: порог «людного» телефона УБРАН. У застройщика один номер отдела
|
||
// продаж легитимно висит на всех его ЖК (СМ.Сити: 9 проектов на 277-99-01) — старый порог 4
|
||
// прятал это. Рекламные-подменыши отсекаются раньше (на сборе), а тихой ложной склейки нет:
|
||
// группу лишь ПОКАЗЫВАЕМ, склейку клиент подтверждает вручную («Это разные»). Проверено на
|
||
// AutoMoney: его номер висит у 3 карточек — все реально Автодом24, группа верная.
|
||
$items = [];
|
||
for ($i = 1; $i <= 9; $i++) {
|
||
$items[] = ['id' => $i, 'keys' => ['p:73912779901']];
|
||
}
|
||
$groups = mergeService()->duplicateGroups($items);
|
||
|
||
expect($groups)->toHaveCount(1);
|
||
expect($groups[0]['ids'])->toBe([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||
expect(mb_strtolower($groups[0]['reason']))->toContain('телефон');
|
||
});
|
||
|
||
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('Драйв займ');
|
||
});
|