feat(autopodbor): при слиянии конкурентов проекты переименовываются в имя выжившего

Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого
получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой.
Проекты не удаляются и не рвутся — меняется только имя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-05 06:10:18 +03:00
parent 505b8ed059
commit 26bebf4b7e
2 changed files with 59 additions and 4 deletions
@@ -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<int, AutopodborCompetitor> $comps
* @param Collection<int, AutopodborCompetitor> $comps
*/
private function pickSurvivor($comps): AutopodborCompetitor
{
@@ -124,7 +160,7 @@ final class AutopodborCompetitorMerger
* - иначе оставляем существующего, дубль проигравшего дропаем (источник с проектом никогда не удаляем,
* не оставив эквивалент; редкий кейс «оба с проектом» существующий остаётся, проект второго живёт отдельно).
*
* @param \Illuminate\Support\Collection<string, AutopodborSource> $survivorByKey
* @param Collection<string, AutopodborSource> $survivorByKey
*/
private function moveSource(AutopodborSource $src, int $survivorId, $survivorByKey): void
{
@@ -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, 'Сибиряк', 'сибиряк.рф');