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('слияние пишет событие в журнал со снимком поглощённых карточек (кто, что во что)', function () { $t = mergerTenant(); $a = mkComp($t, 'СМ-сити', 'sm-city.ru', ['https://2gis.ru/x']); $b = mkComp($t, 'Метрополис', null, ['https://yandex.ru/maps/org/metro/9']); $b->update(['phones' => ['73912779901']]); mkSrc($t, $a, 'call', '73912779901', 'call:73912779901', null, 'field'); // у A источник в поле → A выживает $u = User::factory()->create(['tenant_id' => $t->id]); app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id], null, $u->id); $ev = AutopodborMergeEvent::where('tenant_id', $t->id)->latest('id')->first(); expect($ev)->not->toBeNull(); expect($ev->user_id)->toBe($u->id); expect($ev->survivor_id)->toBe($a->id); expect($ev->survivor_name)->toBe('СМ-сити'); // снимок поглощённой карточки — с данными для возврата $absorbed = collect($ev->absorbed); expect($absorbed->pluck('name')->all())->toContain('Метрополис')->not->toContain('СМ-сити'); $metro = $absorbed->firstWhere('name', 'Метрополис'); expect($metro['id'])->toBe($b->id) ->and($metro['phones'])->toContain('73912779901') ->and($metro['directory_urls'])->toContain('https://yandex.ru/maps/org/metro/9'); }); 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(); // Предложение создаём ПЕРВЫМ (меньший id): при отсутствии проектов оно выигрывает выживание // по возрасту (tiebreak). Раньше объединённая уезжала в «Предложения» вслед за выжившим. $prop = mkComp($t, 'Дубль-предложение', 'dup.ru'); $prop->update(['box' => 'proposal']); $field = mkComp($t, 'Моя карточка поля', 'dup.ru'); $field->update(['box' => 'field']); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$prop->id, $field->id]); expect($survivorId)->toBe($prop->id) // выживает старейший (предложение) ->and(AutopodborCompetitor::find($survivorId)->box)->toBe('field'); // но объединённая — в ПОЛЕ }); it('слияние двух предложений — объединённая остаётся в предложениях (без ложного повышения)', function () { $t = mergerTenant(); $a = mkComp($t, 'Предложение А', 'a.ru'); $a->update(['box' => 'proposal']); $b = mkComp($t, 'Предложение Б', 'a.ru'); $b->update(['box' => 'proposal']); $survivorId = app(AutopodborCompetitorMerger::class)->merge($t->id, [$a->id, $b->id]); expect(AutopodborCompetitor::find($survivorId)->box)->toBe('proposal'); }); 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'); });