From 26bebf4b7e8944a01a8e8d9f3f5bc58a7ebf100f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 5 Jul 2026 06:10:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(autopodbor):=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=B8=D0=BC=D1=8F=20=D0=B2=D1=8B=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=88=D0=B5=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой. Проекты не удаляются и не рвутся — меняется только имя. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/AutopodborCompetitorMerger.php | 44 +++++++++++++++++-- .../AutopodborCompetitorMergerTest.php | 19 ++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/app/Services/Autopodbor/AutopodborCompetitorMerger.php b/app/app/Services/Autopodbor/AutopodborCompetitorMerger.php index 9cb93799..184a7fb8 100644 --- a/app/app/Services/Autopodbor/AutopodborCompetitorMerger.php +++ b/app/app/Services/Autopodbor/AutopodborCompetitorMerger.php @@ -6,6 +6,8 @@ namespace App\Services\Autopodbor; use App\Models\AutopodborCompetitor; use App\Models\AutopodborSource; +use App\Models\Project; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -13,11 +15,14 @@ use InvalidArgumentException; * Слияние конкурентов-дублей в одного (финальный проход «Почистить поле», подтверждается клиентом). * * Project-safe: источник, по которому создан проект (created_project_id), при склейке НЕ теряется — - * переносится на выжившего. Удаляется только дубль-строка конкурента; проекты (отдельные строки) - * не трогаются. Всё в одной транзакции. + * переносится на выжившего. Удаляется только дубль-строка конкурента; сами проекты не удаляются и + * не рвутся — лишь ПЕРЕИМЕНОВЫВАЮТСЯ в имя выжившего (имя проекта следует за конкурентом). Всё в + * одной транзакции. */ final class AutopodborCompetitorMerger { + public function __construct(private AutopodborProjectCreator $namer) {} + /** * Сливает конкурентов $competitorIds одного тенанта в одного. Возвращает id выжившего. * $name — выбранное клиентом имя объединённого (из найденных); null → имя выжившего не трогаем. @@ -82,6 +87,10 @@ final class AutopodborCompetitorMerger $survivor->save(); + // Имя проекта следует за конкурентом: после склейки все проекты объединённого + // получают имя выжившего (при совпадении — с источником, без бессмысленной цифры). + $this->renameProjectsToSurvivor($tenantId, $survivor); + // Удаляем строки проигравших. Их источники уже перенесены/дропнуты (коллизии), // оставшиеся хвосты добьёт FK CASCADE. AutopodborCompetitor::query() @@ -93,11 +102,38 @@ final class AutopodborCompetitorMerger }); } + /** + * Переименовывает проекты всех источников выжившего в имя выжившего конкурента + * (nameForSource: при совпадении различаем источником, а не цифрой). Идёт по возрастанию id — + * первый проект получает чистое имя, остальные — «имя · источник». + */ + private function renameProjectsToSurvivor(int $tenantId, AutopodborCompetitor $survivor): void + { + $sources = AutopodborSource::query() + ->where('tenant_id', $tenantId) + ->where('competitor_id', $survivor->id) + ->whereNotNull('created_project_id') + ->orderBy('id') + ->get(); + + foreach ($sources as $src) { + $src->setRelation('competitor', $survivor); // имя берём у выжившего, без лишнего запроса + $project = Project::where('tenant_id', $tenantId)->find($src->created_project_id); + if ($project === null) { + continue; + } + $newName = $this->namer->nameForSource($tenantId, $src, $project->id); + if ($newName !== $project->name) { + $project->update(['name' => $newName]); + } + } + } + /** * Выживший = у кого больше источников с проектом (деньги вложены) → больше в работе (box=field) * → наименьший id (старейший). Так деньги/работа не рушатся. * - * @param \Illuminate\Support\Collection $comps + * @param Collection $comps */ private function pickSurvivor($comps): AutopodborCompetitor { @@ -124,7 +160,7 @@ final class AutopodborCompetitorMerger * - иначе → оставляем существующего, дубль проигравшего дропаем (источник с проектом никогда не удаляем, * не оставив эквивалент; редкий кейс «оба с проектом» — существующий остаётся, проект второго живёт отдельно). * - * @param \Illuminate\Support\Collection $survivorByKey + * @param Collection $survivorByKey */ private function moveSource(AutopodborSource $src, int $survivorId, $survivorByKey): void { diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorMergerTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorMergerTest.php index dbc4be7d..0640f9de 100644 --- a/app/tests/Feature/Autopodbor/AutopodborCompetitorMergerTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorMergerTest.php @@ -134,6 +134,25 @@ it('слияние без имени — имя выжившего не трог 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, 'Сибиряк', 'сибиряк.рф');