companies: Zusammenführung im Bestand, rückabwickelbar (Roadmap 1a.3)
companies:merge führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom zusammen — nur über Adressen, die 1a.2 als Dublette oder Namensvariante klassifiziert hat. Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen; Meldungen, Kontakte, Nutzer- und Zahlungsverknüpfungen wandern per company_id, jede Bewegung steht in company_merge_moves. companies:unmerge spielt sie rückwärts ab. 77 % der Gruppen sind dieselbe Firma auf beiden Portalen (Altsystem: eine Firma je Portal). Der Überlebende wird dann `both`; die Meldungen behalten ihr Portal. Der alte Newsroom-Slug führt per 301 zum Überlebenden. Slug-Kollisionen auf dem anderen Portal werden übersprungen, nicht umbenannt. Company::slugExists() kennt jetzt `both`. Dev-DB: 14 142 Adressen, 16 194 Firmen, 13 297 Meldungen in 2 min; Rückabwicklung in 1:15 min exakt auf den Stand davor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66d8398e97
commit
2aebb9d27e
17 changed files with 1508 additions and 7 deletions
26
app/Services/Company/CompanyMergePlan.php
Normal file
26
app/Services/Company/CompanyMergePlan.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
|
||||
/**
|
||||
* Was eine Zusammenführung tun würde — ohne es zu tun. Der Trockenlauf zeigt
|
||||
* den Plan, der scharfe Lauf führt ihn aus; beide sehen dasselbe.
|
||||
*/
|
||||
final readonly class CompanyMergePlan
|
||||
{
|
||||
/**
|
||||
* @param list<Company> $losers die Firmen, die im Überlebenden aufgehen
|
||||
* @param list<Company> $excluded Firmen der Adresse, die bewusst außen vor bleiben (410-entfernt)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public Company $survivor,
|
||||
public Portal $targetPortal,
|
||||
public array $losers,
|
||||
public array $excluded,
|
||||
public int $pressReleases,
|
||||
) {}
|
||||
}
|
||||
479
app/Services/Company/CompanyMergeService.php
Normal file
479
app/Services/Company/CompanyMergeService.php
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Models\CompanyMerge;
|
||||
use App\Models\CompanyMergeMove;
|
||||
use App\Models\Contact;
|
||||
use App\Models\ConversionEvent;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAiDeclaration;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Zusammenführung mehrerer Firmen zu einem Newsroom (Roadmap 1a.3).
|
||||
*
|
||||
* Schlüssel ist die Firmen-E-Mail; ob über eine Adresse zusammengeführt werden
|
||||
* darf, sagt `CompanyEmailGroup::isMergeable()` — nie diese Klasse. Sie
|
||||
* entscheidet nur noch, WIE: welche Firma bleibt, was zu ihr wandert, und sie
|
||||
* schreibt jede Bewegung nach `company_merge_moves`, damit `revert()` den
|
||||
* Vorgang exakt umkehren kann.
|
||||
*
|
||||
* Drei Regeln, am 15.09.2026 so entschieden:
|
||||
*
|
||||
* 1. Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen,
|
||||
* bei Gleichstand die ältere. Leere Stammdatenfelder füllt sie aus den
|
||||
* wegfallenden Firmen auf; belegte bleiben.
|
||||
* 2. Meldungen, Kontakte, Nutzer- und Zahlungsverknüpfungen wandern per
|
||||
* `company_id`; jede Bewegung wird protokolliert. Die Meldungen behalten
|
||||
* ihr Portal — nur die Firma wird `both`, wenn ihre Hälften auf
|
||||
* verschiedenen Portalen lagen. Das ist der Normalfall: Das Altsystem hat
|
||||
* jede Firma je Portal einmal angelegt, 77 % der Gruppen sind solche Paare.
|
||||
* 3. Die wegfallende Firma wird soft-deleted. Ihr alter Newsroom-Slug führt
|
||||
* per 301 zum Überlebenden (siehe NewsroomDetailPageData). Kein 410 — die
|
||||
* Firma gibt es ja noch, nur woanders.
|
||||
*
|
||||
* Wird der Überlebende `both`, muss sein Slug auf dem anderen Portal frei
|
||||
* sein, sonst fände die öffentliche Auflösung zwei Firmen. Ist er das bei
|
||||
* keinem Kandidaten, wird die Adresse übersprungen — nicht umbenannt, die URL
|
||||
* ist öffentlich.
|
||||
*/
|
||||
class CompanyMergeService
|
||||
{
|
||||
/**
|
||||
* Stammdaten, die der Überlebende aus wegfallenden Firmen auffüllt, wenn
|
||||
* sie bei ihm leer sind. Bewusst ohne `name`, `slug`, `email`, `portal` —
|
||||
* das ist seine Identität — und ohne `type`.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const FillableFields = [
|
||||
'website', 'phone', 'fax', 'address', 'country_code', 'logo_path', 'logo_variants', 'boilerplate', 'owner_user_id',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Was für diese Adresse geschehen würde. Liest nur.
|
||||
*
|
||||
* @param Collection<int, Company>|null $members die Firmen der Adresse, wenn der Aufrufer sie schon geladen hat
|
||||
*/
|
||||
public function plan(CompanyEmailGroup $group, ?Collection $members = null): CompanyMergePlan|CompanyMergeSkip
|
||||
{
|
||||
if (! $group->isMergeable()) {
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::NotMergeable, [$group->effectiveClassification()->value]);
|
||||
}
|
||||
|
||||
$members ??= $this->membersOf($group->email);
|
||||
|
||||
// 410-entfernte Firmen bleiben außen vor: Ihre Meldungen in den
|
||||
// Überlebenden zu heben, würde bewusst entfernte Inhalte wieder
|
||||
// sichtbar machen.
|
||||
[$excluded, $candidates] = $members->partition(fn (Company $company): bool => $company->gone_at !== null);
|
||||
|
||||
if ($candidates->count() < 2) {
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::NothingToMerge, $excluded->isNotEmpty() ? ['410-entfernt: '.$excluded->count()] : []);
|
||||
}
|
||||
|
||||
$ranked = $this->rankSurvivors($candidates);
|
||||
$portals = $candidates->map(fn (Company $company): string => $company->portal->value)->unique()->values()->all();
|
||||
$blocked = [];
|
||||
|
||||
foreach ($ranked as $survivor) {
|
||||
$targetPortal = $this->targetPortal($survivor, $portals);
|
||||
|
||||
if ($targetPortal === Portal::Both && $survivor->portal !== Portal::Both
|
||||
&& $this->slugTakenElsewhere($survivor, $candidates)) {
|
||||
$blocked[] = $survivor->slug;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$losers = $ranked->reject(fn (Company $company): bool => $company->is($survivor))->values();
|
||||
|
||||
return new CompanyMergePlan(
|
||||
email: $group->email,
|
||||
survivor: $survivor,
|
||||
targetPortal: $targetPortal,
|
||||
losers: $losers->all(),
|
||||
excluded: $excluded->values()->all(),
|
||||
pressReleases: $this->pressReleasesOf($losers->modelKeys())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::SlugCollision, array_unique($blocked));
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt den Plan aus. Alles in einer Transaktion je Adresse: Entweder die
|
||||
* Firma ist zusammengeführt und jede Bewegung protokolliert, oder nichts
|
||||
* ist passiert.
|
||||
*
|
||||
* @param bool $flushCaches false, wenn der Aufrufer viele Adressen nacheinander verarbeitet und einmal am Ende leert
|
||||
*/
|
||||
public function merge(CompanyMergePlan $plan, string $batch, bool $flushCaches = true): CompanyMerge
|
||||
{
|
||||
$merge = DB::transaction(function () use ($plan, $batch): CompanyMerge {
|
||||
$survivor = $plan->survivor;
|
||||
|
||||
$merge = CompanyMerge::query()->create([
|
||||
'batch' => $batch,
|
||||
'email' => $plan->email,
|
||||
'survivor_company_id' => $survivor->getKey(),
|
||||
'survivor_portal_before' => $survivor->portal->value,
|
||||
'merged_companies_count' => count($plan->losers),
|
||||
'moved_press_releases_count' => 0,
|
||||
]);
|
||||
|
||||
$moved = 0;
|
||||
$fills = [];
|
||||
|
||||
foreach ($plan->losers as $loser) {
|
||||
$moved += $this->moveReferences($merge, $loser, $survivor);
|
||||
$this->moveUserLinks($merge, $loser, $survivor);
|
||||
$this->movePaymentOptions($merge, $loser, $survivor);
|
||||
$fills += $this->collectFills($merge, $loser, $survivor, array_keys($fills));
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Company, $loser->getKey(), $loser);
|
||||
|
||||
// Query-Builder statt $loser->delete(): Der Observer würde je
|
||||
// Firma den Admin-Cache leeren, bei zehntausenden Firmen je
|
||||
// Lauf. Der Aufrufer leert einmal am Ende.
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($loser->getKey())->update(['deleted_at' => now()]);
|
||||
}
|
||||
|
||||
$survivorChanges = $fills;
|
||||
|
||||
if ($plan->targetPortal !== $survivor->portal) {
|
||||
$survivorChanges['portal'] = $plan->targetPortal->value;
|
||||
}
|
||||
|
||||
if (! $survivor->is_active && collect($plan->losers)->contains(fn (Company $loser): bool => (bool) $loser->is_active)) {
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0');
|
||||
$survivorChanges['is_active'] = true;
|
||||
}
|
||||
|
||||
if ($survivorChanges !== []) {
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivor->getKey())->update($survivorChanges);
|
||||
}
|
||||
|
||||
$merge->update(['moved_press_releases_count' => $moved]);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf('Newsroom „%s“: %d Firma/Firmen unter %s zusammengeführt, %d Meldungen übernommen', $survivor->name, count($plan->losers), $plan->email, $moved),
|
||||
subject: $survivor,
|
||||
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $batch],
|
||||
actorLabel: 'companies:merge',
|
||||
);
|
||||
|
||||
return $merge;
|
||||
});
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
|
||||
return $merge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt die Bewegungen rückwärts ab. Was seit der Zusammenführung NEU
|
||||
* beim Überlebenden entstanden ist, bleibt bei ihm — die Rückabwicklung
|
||||
* kennt nur, was sie selbst verschoben hat.
|
||||
*/
|
||||
public function revert(CompanyMerge $merge, bool $flushCaches = true): void
|
||||
{
|
||||
if ($merge->isReverted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merge->loadMissing('survivor');
|
||||
|
||||
DB::transaction(function () use ($merge): void {
|
||||
$survivorId = $merge->survivor_company_id;
|
||||
$moves = $merge->moves()->orderByDesc('id')->get();
|
||||
|
||||
foreach ($moves as $move) {
|
||||
$this->undo($move, $survivorId);
|
||||
}
|
||||
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)
|
||||
->update(['portal' => $merge->survivor_portal_before->value]);
|
||||
|
||||
$merge->update(['reverted_at' => now()]);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf('Zusammenführung unter %s rückabgewickelt (%d Firmen, %d Meldungen)', $merge->email, $merge->merged_companies_count, $merge->moved_press_releases_count),
|
||||
subject: $merge->survivor,
|
||||
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $merge->batch],
|
||||
actorLabel: 'companies:unmerge',
|
||||
);
|
||||
});
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
}
|
||||
|
||||
public function flushCaches(): void
|
||||
{
|
||||
WebCaches::forgetPublicPages();
|
||||
app(AdminPerformanceCache::class)->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle lebenden Firmen unter dieser Adresse, portalübergreifend.
|
||||
*
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function membersOf(string $email): Collection
|
||||
{
|
||||
// Der Index auf `email` ist case-insensitiv (utf8mb4_unicode_ci); der
|
||||
// PHP-Filter danach ist die Rückversicherung, dass genau die Firmen
|
||||
// kommen, die die Klassifizierung unter diesem Schlüssel gezählt hat.
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->where('email', $email)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->filter(fn (Company $company): bool => Company::emailKey($company->email) === $email)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kandidaten nach Regel 1: meiste jemals veröffentlichte Meldungen, bei
|
||||
* Gleichstand die ältere (kleinere ID). „Jemals" schließt Meldungen mit
|
||||
* Aufrufen ein — der Altbestand trägt Hunderttausende offline genommene
|
||||
* Meldungen unter `rejected`, die online waren.
|
||||
*
|
||||
* @param Collection<int, Company> $candidates
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
private function rankSurvivors(Collection $candidates): Collection
|
||||
{
|
||||
$published = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('company_id', $candidates->modelKeys())
|
||||
->where(fn (Builder $query) => $query->where('status', 'published')->orWhere('hits', '>', 0))
|
||||
->selectRaw('company_id, count(*) as n')
|
||||
->groupBy('company_id')
|
||||
->pluck('n', 'company_id');
|
||||
|
||||
return $candidates
|
||||
->sortBy([
|
||||
fn (Company $a, Company $b): int => ($published[$b->getKey()] ?? 0) <=> ($published[$a->getKey()] ?? 0),
|
||||
fn (Company $a, Company $b): int => $a->getKey() <=> $b->getKey(),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $portals Portale aller Kandidaten
|
||||
*/
|
||||
private function targetPortal(Company $survivor, array $portals): Portal
|
||||
{
|
||||
if ($survivor->portal === Portal::Both || count($portals) > 1) {
|
||||
return Portal::Both;
|
||||
}
|
||||
|
||||
return $survivor->portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trägt eine fremde, lebende Firma auf einem anderen Portal denselben
|
||||
* Slug? Dann fände die öffentliche Auflösung (`portal IN (eigenes, both)`)
|
||||
* nach dem Wechsel auf `both` zwei Firmen. Die eigenen Gruppenmitglieder
|
||||
* zählen nicht — die sind danach soft-deleted.
|
||||
*
|
||||
* @param Collection<int, Company> $group
|
||||
*/
|
||||
private function slugTakenElsewhere(Company $survivor, Collection $group): bool
|
||||
{
|
||||
// Die anderen Portale ausdrücklich aufzählen statt `portal != ?`:
|
||||
// Nur so greift der Unique-Index (portal, slug) — der Unterschied ist
|
||||
// 24 ms gegen 0 ms je Adresse, bei vierzehntausend Adressen sechs
|
||||
// Minuten gegen eine Sekunde.
|
||||
$otherPortals = array_values(array_diff(
|
||||
array_map(fn (Portal $portal): string => $portal->value, Portal::cases()),
|
||||
[$survivor->portal->value],
|
||||
));
|
||||
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $otherPortals)
|
||||
->where('slug', $survivor->slug)
|
||||
->whereKeyNot($group->modelKeys())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $companyIds
|
||||
* @return Builder<PressRelease>
|
||||
*/
|
||||
private function pressReleasesOf(array $companyIds): Builder
|
||||
{
|
||||
return PressRelease::query()->withoutGlobalScopes()->whereIn('company_id', $companyIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldungen, Kontakte, KI-Erklärungen und Konversionsereignisse per
|
||||
* `company_id` zum Überlebenden. Jede ID wird vorher protokolliert; das
|
||||
* Update läuft über den Query-Builder (keine Model-Events — die
|
||||
* Suchspiegelung enthält keine Firmendaten, ein Reindex ist unnötig).
|
||||
*
|
||||
* @return int verschobene Meldungen
|
||||
*/
|
||||
private function moveReferences(CompanyMerge $merge, Company $loser, Company $survivor): int
|
||||
{
|
||||
$moved = 0;
|
||||
|
||||
$tables = [
|
||||
[CompanyMergeSubject::PressRelease, $this->pressReleasesOf([$loser->getKey()])],
|
||||
[CompanyMergeSubject::Contact, Contact::query()->withoutGlobalScopes()->where('company_id', $loser->getKey())],
|
||||
[CompanyMergeSubject::AiDeclaration, PressReleaseAiDeclaration::query()->where('company_id', $loser->getKey())],
|
||||
[CompanyMergeSubject::ConversionEvent, ConversionEvent::query()->where('company_id', $loser->getKey())],
|
||||
];
|
||||
|
||||
foreach ($tables as [$subject, $query]) {
|
||||
$ids = $query->pluck('id')->all();
|
||||
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_chunk($ids, 500) as $chunk) {
|
||||
CompanyMergeMove::query()->insert(array_map(fn (int $id): array => [
|
||||
'company_merge_id' => $merge->getKey(),
|
||||
'subject' => $subject->value,
|
||||
'subject_id' => $id,
|
||||
'from_company_id' => $loser->getKey(),
|
||||
'field' => null,
|
||||
'previous_value' => null,
|
||||
'dropped' => false,
|
||||
], $chunk));
|
||||
}
|
||||
|
||||
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
|
||||
|
||||
if ($subject === CompanyMergeSubject::PressRelease) {
|
||||
$moved = count($ids);
|
||||
}
|
||||
}
|
||||
|
||||
return $moved;
|
||||
}
|
||||
|
||||
/**
|
||||
* `company_user` als Vereinigung: Wer nur bei der wegfallenden Firma
|
||||
* stand, wandert; wer schon beim Überlebenden steht, wird dort nicht
|
||||
* doppelt — sein alter Eintrag fällt weg und wird als `dropped` mit Rolle
|
||||
* protokolliert, damit die Rückabwicklung ihn neu anlegt.
|
||||
*/
|
||||
private function moveUserLinks(CompanyMerge $merge, Company $loser, Company $survivor): void
|
||||
{
|
||||
$existing = DB::table('company_user')->where('company_id', $survivor->getKey())->pluck('user_id')->flip();
|
||||
|
||||
foreach (DB::table('company_user')->where('company_id', $loser->getKey())->get() as $link) {
|
||||
$dropped = isset($existing[$link->user_id]);
|
||||
$this->record($merge, CompanyMergeSubject::CompanyUser, (int) $link->user_id, $loser, null, (string) $link->role, $dropped);
|
||||
|
||||
$query = DB::table('company_user')->where('company_id', $loser->getKey())->where('user_id', $link->user_id);
|
||||
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function movePaymentOptions(CompanyMerge $merge, Company $loser, Company $survivor): void
|
||||
{
|
||||
$existing = DB::table('user_payment_option_company')->where('company_id', $survivor->getKey())->pluck('user_payment_option_id')->flip();
|
||||
|
||||
foreach (DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->get() as $link) {
|
||||
$dropped = isset($existing[$link->user_payment_option_id]);
|
||||
$this->record($merge, CompanyMergeSubject::PaymentOption, (int) $link->user_payment_option_id, $loser, null, (string) (int) $link->is_active, $dropped);
|
||||
|
||||
$query = DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->where('user_payment_option_id', $link->user_payment_option_id);
|
||||
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leere Felder des Überlebenden aus dieser wegfallenden Firma. Erste
|
||||
* Firma, die einen Wert hat, gewinnt — die Reihenfolge ist die Rangfolge
|
||||
* aus Regel 1.
|
||||
*
|
||||
* @param list<string> $alreadyFilled
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function collectFills(CompanyMerge $merge, Company $loser, Company $survivor, array $alreadyFilled): array
|
||||
{
|
||||
$fills = [];
|
||||
|
||||
foreach (self::FillableFields as $field) {
|
||||
if (in_array($field, $alreadyFilled, true) || ! blank($survivor->getRawOriginal($field)) || blank($loser->getRawOriginal($field))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field));
|
||||
$fills[$field] = $loser->getRawOriginal($field);
|
||||
}
|
||||
|
||||
return $fills;
|
||||
}
|
||||
|
||||
private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false): void
|
||||
{
|
||||
CompanyMergeMove::query()->create([
|
||||
'company_merge_id' => $merge->getKey(),
|
||||
'subject' => $subject,
|
||||
'subject_id' => $subjectId,
|
||||
'from_company_id' => $from->getKey(),
|
||||
'field' => $field,
|
||||
'previous_value' => $previous,
|
||||
'dropped' => $dropped,
|
||||
]);
|
||||
}
|
||||
|
||||
private function undo(CompanyMergeMove $move, int $survivorId): void
|
||||
{
|
||||
$from = $move->from_company_id;
|
||||
|
||||
match ($move->subject) {
|
||||
CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::Contact => Contact::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::AiDeclaration => PressReleaseAiDeclaration::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::ConversionEvent => ConversionEvent::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::CompanyUser => $this->undoPivot('company_user', 'user_id', $move, $survivorId, ['role' => $move->previous_value]),
|
||||
CompanyMergeSubject::PaymentOption => $this->undoPivot('user_payment_option_company', 'user_payment_option_id', $move, $survivorId, ['is_active' => (bool) $move->previous_value]),
|
||||
CompanyMergeSubject::Field => Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([(string) $move->field => $move->previous_value]),
|
||||
CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function undoPivot(string $table, string $keyColumn, CompanyMergeMove $move, int $survivorId, array $extra): void
|
||||
{
|
||||
if ($move->dropped) {
|
||||
DB::table($table)->insertOrIgnore(['company_id' => $move->from_company_id, $keyColumn => $move->subject_id, 'created_at' => now(), 'updated_at' => now()] + $extra);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table($table)->where('company_id', $survivorId)->where($keyColumn, $move->subject_id)->update(['company_id' => $move->from_company_id]);
|
||||
}
|
||||
}
|
||||
26
app/Services/Company/CompanyMergeSkip.php
Normal file
26
app/Services/Company/CompanyMergeSkip.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
/**
|
||||
* Eine Adresse, die im Lauf übersprungen wurde, mit dem Grund. Gründe sind
|
||||
* kurze, feste Schlüssel, damit der Lauf sie zählen und der Mensch die Liste
|
||||
* danach filtern kann.
|
||||
*/
|
||||
final readonly class CompanyMergeSkip
|
||||
{
|
||||
public const NotMergeable = 'nicht_zusammenfuehrbar';
|
||||
|
||||
public const NothingToMerge = 'nichts_zusammenzufuehren';
|
||||
|
||||
public const SlugCollision = 'slug_kollision';
|
||||
|
||||
/**
|
||||
* @param list<string> $details
|
||||
*/
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public string $reason,
|
||||
public array $details = [],
|
||||
) {}
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyMergeMove;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\WebPageCache;
|
||||
|
|
@ -67,6 +69,50 @@ class NewsroomDetailPageData
|
|||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug einer Firma, die in einer Zusammenführung (Roadmap 1a.3) in einer
|
||||
* anderen aufgegangen ist → Slug des Überlebenden, sofern der abweicht.
|
||||
* Die alte URL ist öffentlich und verlinkt; sie führt per 301 weiter statt
|
||||
* ins Leere. Bei gleichem Slug kommt dieser Zweig gar nicht vor — dann hat
|
||||
* die Auflösung oben den Überlebenden (`both`) schon gefunden.
|
||||
*
|
||||
* @param list<string> $portalValues
|
||||
*/
|
||||
private function mergedInto(array $portalValues, string $slug): ?string
|
||||
{
|
||||
$mergedIds = Company::onlyTrashed()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('slug', $slug)
|
||||
->pluck('id');
|
||||
|
||||
if ($mergedIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$survivorId = CompanyMergeMove::query()
|
||||
->where('subject', CompanyMergeSubject::Company->value)
|
||||
->whereIn('subject_id', $mergedIds)
|
||||
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
||||
->with('merge:id,survivor_company_id')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->merge
|
||||
?->survivor_company_id;
|
||||
|
||||
if ($survivorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($survivorId)
|
||||
->where('is_active', true)
|
||||
->value('slug');
|
||||
|
||||
return $target !== null && $target !== $slug ? $target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -84,6 +130,12 @@ class NewsroomDetailPageData
|
|||
->first();
|
||||
|
||||
if (! $company) {
|
||||
$target = $this->mergedInto($portalValues, $slug);
|
||||
|
||||
if ($target !== null) {
|
||||
return ['redirect' => $target];
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue