*/ 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|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 && $survivor->slugTakenOnOtherPortals($candidates->modelKeys())) { $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', merged: '1'); $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; }); $this->reassignCanonicals($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', ); }); $this->reassignCanonicals($merge); if ($flushCaches) { $this->flushCaches(); } } /** * Canonical-Zuordnung der verschobenen Meldungen neu bewerten (Umsetzungs- * audit 29.09.2026, R-06): Nach dem Merge gehören Fassungen gleichen * Inhalts auf beiden Portalen derselben Firma und werden zum Paar; nach * dem Unmerge zeigt eine Kopie sonst auf die Meldung einer fremden Firma. * Bewusst nach dem Commit — die Zuordnung ist ableitbar und lässt sich * mit `press-releases:assign-canonicals` jederzeit nachziehen. */ private function reassignCanonicals(CompanyMerge $merge): void { $assigner = app(CanonicalAssigner::class); if (! $assigner->enabled()) { return; } $moves = $merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value); $companyIds = $merge->moves()->distinct()->pluck('from_company_id')->push($merge->survivor_company_id)->unique()->values()->all(); $moves->select(['id', 'subject_id'])->chunkById(500, function ($chunk) use ($assigner, $companyIds): void { $hashes = PressRelease::query()->withoutGlobalScopes() ->whereIn('id', $chunk->pluck('subject_id')->all()) ->whereNotNull('content_hash') ->distinct() ->pluck('content_hash') ->all(); $assigner->reassignWithin($hashes, $companyIds); }); } public function flushCaches(): void { WebCaches::forgetPublicPages(); app(AdminPerformanceCache::class)->flush(); } /** * Alle lebenden Firmen unter dieser Adresse, portalübergreifend. * * @return Collection */ 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 $candidates * @return Collection */ 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 $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; } /** * @param list $companyIds * @return Builder */ 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)); } if ($subject === CompanyMergeSubject::PressRelease) { // toBase(): ohne updated_at — der Inhalt der Meldung ändert // sich nicht (Umsetzungsaudit 29.09.2026, R-02). $query->whereIn('id', $ids)->toBase()->update(['company_id' => $survivor->getKey()]); $moved = count($ids); continue; } $query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]); } 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 $alreadyFilled * @return array */ 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), merged: (string) $loser->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, ?string $merged = null): void { CompanyMergeMove::query()->create([ 'company_merge_id' => $merge->getKey(), 'subject' => $subject, 'subject_id' => $subjectId, 'from_company_id' => $from->getKey(), 'field' => $field, 'previous_value' => $previous, 'merged_value' => $merged, '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)->toBase()->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 => $this->undoField($move, $survivorId), CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]), }; } /** * Ein aufgefülltes Feld nur zurücksetzen, wenn es noch den beim Merge * eingefüllten Wert trägt — hat der Kunde es inzwischen geändert (etwa * nach dem Beanspruchen des Newsrooms), bleibt seine Fassung stehen * (Umsetzungsaudit 29.09.2026, R-05). Zeilen von vor der Spalte * `merged_value` vergleichen gegen die Herkunftsfirma: Die wurde beim * Merge nur soft-gelöscht, ihr Wert ist der eingefüllte. */ private function undoField(CompanyMergeMove $move, int $survivorId): void { $field = (string) $move->field; $filled = $move->merged_value; if ($filled === null) { $filled = $field === 'is_active' ? '1' : Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($move->from_company_id)->toBase()->value($field); } $current = Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->toBase()->value($field); if (! $this->sameRawValue($current, $filled)) { return; } Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([$field => $move->previous_value]); } private function sameRawValue(mixed $current, mixed $filled): bool { if (is_bool($current)) { $current = (int) $current; } if (is_bool($filled)) { $filled = (int) $filled; } return $current !== null && $filled !== null && (string) $current === (string) $filled; } /** * @param array $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]); } }