create(['balance_rub' => '100000.00']); DB::statement('SET app.current_tenant_id = '.$t->id); return $t; } function mkComp(Tenant $t, string $name, ?string $site, array $dirs = []): AutopodborCompetitor { $run = AutopodborRun::create(['tenant_id' => $t->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); return AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => $name, 'dedup_key' => 'c:'.md5($name), 'site_url' => $site, 'directory_urls' => $dirs, ]); } function mkSrc(Tenant $t, AutopodborCompetitor $c, string $type, string $ident, string $dedupKey, ?int $projectId = null, string $box = 'field'): AutopodborSource { $run = AutopodborRun::create(['tenant_id' => $t->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'competitor_id' => $c->id, 'params' => []]); return AutopodborSource::create([ 'tenant_id' => $t->id, 'competitor_id' => $c->id, 'study_run_id' => $run->id, 'signal_type' => $type, 'identifier' => $ident, 'dedup_key' => $dedupKey, 'box' => $box, 'confirmations' => 1, 'created_project_id' => $projectId, ]); } it('переносит источники проигравшего на выжившего, дубль-конкурента удаляет, справочники объединяет', function () { $t = mergerTenant(); $a = mkComp($t, 'AutoMoney', 'automoney-krsk.ru', ['https://2gis.ru/krasnoyarsk/firm/1']); $b = mkComp($t, 'Автодом24', 'automoney-krsk.ru', ['https://yandex.ru/maps/org/x/2']); mkSrc($t, $a, 'site', 'automoney-krsk.ru', 'site:automoney-krsk.ru'); mkSrc($t, $b, 'call', '79135396546', 'call:79135396546'); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); expect(AutopodborSource::where('competitor_id', $survivorId)->count())->toBe(2); $loserId = $survivorId === $a->id ? $b->id : $a->id; expect(AutopodborCompetitor::find($loserId))->toBeNull(); $s = AutopodborCompetitor::find($survivorId); expect($s->directory_urls)->toContain('https://2gis.ru/krasnoyarsk/firm/1')->toContain('https://yandex.ru/maps/org/x/2'); }); it('источник с проектом не теряется — переносится, created_project_id сохранён, конкурент с проектом выживает', function () { $t = mergerTenant(); $a = mkComp($t, 'A', 'a.ru'); $b = mkComp($t, 'B', 'a.ru'); $project = Project::factory()->create(['tenant_id' => $t->id, 'is_active' => true, 'preflight_blocked_at' => null]); mkSrc($t, $b, 'call', '79001112233', 'call:79001112233', $project->id); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); expect($survivorId)->toBe($b->id); // у B источник с проектом → он выживает $src = AutopodborSource::where('created_project_id', $project->id)->first(); expect($src)->not->toBeNull(); expect($src->competitor_id)->toBe($survivorId); }); it('коллизия ключа: у выжившего источник с проектом, дубль без проекта отброшен, проект цел', function () { $t = mergerTenant(); $project = Project::factory()->create(['tenant_id' => $t->id, 'is_active' => true, 'preflight_blocked_at' => null]); $a = mkComp($t, 'A', 'a.ru'); mkSrc($t, $a, 'call', '79001112233', 'call:79001112233', $project->id); $b = mkComp($t, 'B', 'a.ru'); mkSrc($t, $b, 'call', '79001112233', 'call:79001112233'); // тот же ключ, без проекта $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); expect($survivorId)->toBe($a->id); expect(AutopodborSource::where('competitor_id', $survivorId)->where('dedup_key', 'call:79001112233')->count())->toBe(1); $src = AutopodborSource::where('competitor_id', $survivorId)->where('dedup_key', 'call:79001112233')->first(); expect($src->created_project_id)->toBe($project->id); }); it('три конкурента в группе → один выживший, два удалены, все источники у выжившего', function () { $t = mergerTenant(); $a = mkComp($t, 'A', 'x.ru'); $b = mkComp($t, 'B', 'x.ru'); $c = mkComp($t, 'C', 'x.ru'); mkSrc($t, $a, 'site', 'x.ru', 'site:x.ru'); mkSrc($t, $b, 'call', '79001112233', 'call:79001112233'); mkSrc($t, $c, 'call', '79004445566', 'call:79004445566'); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id, $c->id]); expect(AutopodborCompetitor::whereIn('id', [$a->id, $b->id, $c->id])->count())->toBe(1); expect(AutopodborSource::where('competitor_id', $survivorId)->count())->toBe(3); }); it('меньше двух конкурентов тенанта → исключение (ничего не сливаем)', function () { $t = mergerTenant(); $a = mkComp($t, 'A', 'a.ru'); expect(fn () => app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id])) ->toThrow(InvalidArgumentException::class); }); it('слияние принимает выбранное имя для объединённого конкурента', function () { $t = mergerTenant(); $a = mkComp($t, 'Сибиряк', 'a.ru'); $b = mkComp($t, 'Нанжуль', 'a.ru'); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id], 'Сибиряк холдинг'); expect(AutopodborCompetitor::find($survivorId)->name)->toBe('Сибиряк холдинг'); }); it('слияние без имени — имя выжившего не трогаем', function () { $t = mergerTenant(); $a = mkComp($t, 'Альфа', 'a.ru'); $b = mkComp($t, 'Бета', 'a.ru'); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); // выживший — старейший (меньший id) = A, имя сохраняется expect(AutopodborCompetitor::find($survivorId)->name)->toBe('Альфа'); }); it('слияние переименовывает проекты в имя выжившего конкурента', function () { $t = mergerTenant(); $pA = Project::factory()->create(['tenant_id' => $t->id, 'name' => 'Сибиряк', 'is_active' => true, 'preflight_blocked_at' => null]); $pB = Project::factory()->create(['tenant_id' => $t->id, 'name' => 'Другой проект', 'is_active' => true, 'preflight_blocked_at' => null]); $a = mkComp($t, 'Сибиряк', 'sibiryak.ru'); mkSrc($t, $a, 'site', 'sibiryak.ru', 'site:sibiryak.ru', $pA->id); $b = mkComp($t, 'Сибирская Строительная Компания', 'ssk.ru'); mkSrc($t, $b, 'site', 'ssk.ru', 'site:ssk.ru', $pB->id); app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id], 'Сибирская Строительная Компания'); $names = Project::whereIn('id', [$pA->id, $pB->id])->pluck('name')->all(); foreach ($names as $n) { expect(str_starts_with((string) $n, 'Сибирская Строительная Компания'))->toBeTrue(); // имя следует за конкурентом } expect($names)->not->toContain('Сибиряк') // старого имени нет ->and(collect($names)->unique()->count())->toBe(2); // имена всё ещё разные (одно с источником) }); it('слияние складывает элементы всех конкурентов и ничего не теряет', function () { $t = mergerTenant(); $a = mkComp($t, 'Сибиряк', 'сибиряк.рф'); $a->update(['elements' => [['name' => 'Сибиряк', 'sites' => ['сибиряк.рф'], 'cards' => []]]]); $b = mkComp($t, 'Нанжуль-Солнечный', 'nanzhul.ru', ['https://yandex.ru/maps/org/nanzhul_solnechny/1']); $b->update(['elements' => [['name' => 'Нанжуль-Солнечный', 'sites' => ['nanzhul.ru'], 'cards' => ['https://yandex.ru/maps/org/nanzhul_solnechny/1']]]]); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); $s = AutopodborCompetitor::find($survivorId); $names = collect($s->elements)->pluck('name')->sort()->values()->all(); expect($names)->toBe(['Нанжуль-Солнечный', 'Сибиряк']); // сайт проигравшего не потерян — он внутри его элемента $allSites = collect($s->elements)->flatMap(fn ($e) => $e['sites'])->all(); expect($allSites)->toContain('nanzhul.ru'); });