feat(autopodbor): при слиянии конкурентов проекты переименовываются в имя выжившего
Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой. Проекты не удаляются и не рвутся — меняется только имя. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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, 'Сибиряк', 'сибиряк.рф');
|
||||
|
||||
Reference in New Issue
Block a user