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:
Kevin Adametz 2026-09-15 08:43:42 +00:00
parent 66d8398e97
commit 2aebb9d27e
17 changed files with 1508 additions and 7 deletions

View file

@ -0,0 +1,178 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyAddressClass;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* Zusammenführung im Bestand (Roadmap 1a.3).
*
* Läuft über `company_email_groups` und fasst jede zusammenführbare Adresse
* an — Dublette und Namensvariante, eine Handentscheidung übersteuert. Jede
* Adresse ist eine eigene Transaktion mit protokollierten Bewegungen;
* `companies:unmerge` kehrt sie um.
*
* `--limit` ist für den ersten scharfen Lauf gedacht: erst 50 Adressen, dann
* ansehen, dann der Rest. Alle Vorgänge eines Laufs teilen eine Batch-ID.
*/
class MergeCompanies extends Command
{
protected $signature = 'companies:merge
{--dry-run : Nur zeigen, was geschähe}
{--limit= : Höchstens so viele Adressen anfassen}
{--email= : Nur diese eine Adresse}
{--json : Ausgabe als JSON}';
protected $description = 'Führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom zusammen (nur zusammenführbare Adressen).';
public function handle(CompanyMergeService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$batch = (string) Str::uuid();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$groups = $this->groups();
$stats = ['adressen' => 0, 'zusammengefuehrt' => 0, 'firmen_weg' => 0, 'meldungen' => 0, 'both' => 0, 'uebersprungen' => []];
$plans = [];
$skips = [];
foreach ($groups->lazy() as $group) {
if ($limit !== null && $stats['adressen'] >= $limit) {
break;
}
$result = $service->plan($group);
if ($result instanceof CompanyMergeSkip) {
// Eine Adresse, unter der nichts (mehr) zusammenzuführen ist,
// zählt nicht gegen das Limit — sonst füllt sie beim zweiten
// Lauf die ersten 50 Plätze.
if ($result->reason !== CompanyMergeSkip::NothingToMerge) {
$skips[] = $result;
}
$stats['uebersprungen'][$result->reason] = ($stats['uebersprungen'][$result->reason] ?? 0) + 1;
continue;
}
$stats['adressen']++;
$stats['firmen_weg'] += count($result->losers);
$stats['meldungen'] += $result->pressReleases;
$stats['both'] += $result->targetPortal->value === 'both' && $result->survivor->portal->value !== 'both' ? 1 : 0;
if ($dryRun) {
if (count($plans) < 30) {
$plans[] = $result;
}
continue;
}
$service->merge($result, $batch, flushCaches: false);
$stats['zusammengefuehrt']++;
}
if (! $dryRun && $stats['zusammengefuehrt'] > 0) {
$service->flushCaches();
}
if ($this->option('json')) {
$this->line((string) json_encode([
'dry_run' => $dryRun,
'batch' => $dryRun ? null : $batch,
'statistik' => $stats,
'plaene' => array_map(fn (CompanyMergePlan $plan): array => $this->describe($plan), $plans),
'uebersprungen' => array_map(fn (CompanyMergeSkip $skip): array => ['email' => $skip->email, 'grund' => $skip->reason, 'details' => $skip->details], $skips),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info(sprintf(
'%s%d Adressen, %d Firmen gehen im Überlebenden auf, %d Meldungen wandern, %d Firmen werden portalübergreifend (both).',
$dryRun ? '[Dry-Run] ' : '',
$stats['adressen'],
$stats['firmen_weg'],
$stats['meldungen'],
$stats['both'],
));
if (! $dryRun) {
$this->line(sprintf(' Batch %s — Rückabwicklung: companies:unmerge --batch=%s', $batch, $batch));
}
foreach ($stats['uebersprungen'] as $reason => $count) {
$this->line(sprintf(' übersprungen (%s): %d', $reason, $count));
}
if ($plans !== []) {
$this->newLine();
$this->table(
['Adresse', 'bleibt', 'Portal', 'geht auf', 'Meldungen'],
array_map(fn (CompanyMergePlan $plan): array => [
$plan->email,
sprintf('#%d %s', $plan->survivor->getKey(), Str::limit($plan->survivor->name, 40)),
$plan->targetPortal->value,
implode(', ', array_map(fn (Company $loser): string => sprintf('#%d %s', $loser->getKey(), Str::limit($loser->name, 30)), $plan->losers)),
$plan->pressReleases,
], $plans),
);
}
if ($skips !== []) {
$this->newLine();
$this->table(
['übersprungen', 'Grund', 'Details'],
array_map(fn (CompanyMergeSkip $skip): array => [$skip->email, $skip->reason, implode(', ', $skip->details)], array_slice($skips, 0, 50)),
);
}
return self::SUCCESS;
}
/**
* Zusammenführbare Adressen: berechnete Klasse, sofern keine
* Handentscheidung dagegen steht — oder Handentscheidung dafür.
*
* @return Builder<CompanyEmailGroup>
*/
private function groups(): Builder
{
$mergeable = array_map(
fn (CompanyAddressClass $class): string => $class->value,
array_filter(CompanyAddressClass::cases(), fn (CompanyAddressClass $class): bool => $class->isMergeable()),
);
return CompanyEmailGroup::query()
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', Company::emailKey($email)))
->where(function (Builder $query) use ($mergeable): void {
$query->whereIn('manual_classification', $mergeable)
->orWhere(fn (Builder $computed) => $computed->whereNull('manual_classification')->whereIn('classification', $mergeable));
})
->orderBy('id');
}
/**
* @return array<string, mixed>
*/
private function describe(CompanyMergePlan $plan): array
{
return [
'email' => $plan->email,
'bleibt' => ['id' => $plan->survivor->getKey(), 'name' => $plan->survivor->name, 'portal' => $plan->survivor->portal->value, 'slug' => $plan->survivor->slug],
'portal_danach' => $plan->targetPortal->value,
'geht_auf' => array_map(fn (Company $loser): array => ['id' => $loser->getKey(), 'name' => $loser->name, 'portal' => $loser->portal->value, 'slug' => $loser->slug], $plan->losers),
'ausgenommen_410' => array_map(fn (Company $company): int => $company->getKey(), $plan->excluded),
'meldungen' => $plan->pressReleases,
];
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Console\Commands;
use App\Models\CompanyMerge;
use App\Services\Company\CompanyMergeService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
/**
* Rückabwicklung einer Zusammenführung (Roadmap 1a.3): einen Vorgang, alle
* eines Laufs, oder alle einer Adresse. Spielt die protokollierten Bewegungen
* rückwärts ab; was seither neu beim Überlebenden entstand, bleibt dort.
*/
class UnmergeCompanies extends Command
{
protected $signature = 'companies:unmerge
{--merge= : ID eines Vorgangs aus company_merges}
{--batch= : Alle Vorgänge eines Laufs}
{--email= : Alle Vorgänge unter dieser Adresse}
{--dry-run : Nur zählen}';
protected $description = 'Macht eine Newsroom-Zusammenführung rückgängig.';
public function handle(CompanyMergeService $service): int
{
if (! $this->option('merge') && ! $this->option('batch') && ! $this->option('email')) {
$this->error('Bitte --merge=, --batch= oder --email= angeben.');
return self::INVALID;
}
$query = CompanyMerge::query()
->whereNull('reverted_at')
->when($this->option('merge'), fn (Builder $query, string $id) => $query->whereKey((int) $id))
->when($this->option('batch'), fn (Builder $query, string $batch) => $query->where('batch', $batch))
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', mb_strtolower(trim($email))))
->orderByDesc('id');
$merges = $query->get();
if ($merges->isEmpty()) {
$this->warn('Kein offener Vorgang gefunden.');
return self::SUCCESS;
}
$companies = $merges->sum('merged_companies_count');
$releases = $merges->sum('moved_press_releases_count');
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Vorgänge würden rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
return self::SUCCESS;
}
foreach ($merges as $merge) {
$service->revert($merge, flushCaches: false);
}
$service->flushCaches();
$this->info(sprintf('%d Vorgänge rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Enums;
/**
* Was bei einer Newsroom-Zusammenführung (Roadmap 1a.3) verschoben wurde.
* Jede Zeile in `company_merge_moves` trägt genau eine dieser Arten; die
* Rückabwicklung entscheidet daran, wie sie den Datensatz zurückholt.
*/
enum CompanyMergeSubject: string
{
/** `press_releases.company_id`, subject_id = Meldung */
case PressRelease = 'press_release';
/** `contacts.company_id`, subject_id = Kontakt */
case Contact = 'contact';
/** `press_release_ai_declarations.company_id`, subject_id = Erklärung */
case AiDeclaration = 'ai_declaration';
/** `conversion_events.company_id`, subject_id = Ereignis */
case ConversionEvent = 'conversion_event';
/** `company_user`, subject_id = Nutzer; previous_value = Rolle; dropped, wenn der Nutzer am Überlebenden schon stand */
case CompanyUser = 'company_user';
/** `user_payment_option_company`, subject_id = Zahlungsoption; previous_value = is_active; dropped analog */
case PaymentOption = 'payment_option';
/** Stammdatenfeld am Überlebenden aus einer wegfallenden Firma gefüllt; field = Spalte, previous_value = alter Wert */
case Field = 'field';
/** Die wegfallende Firma selbst (Soft Delete), subject_id = from_company_id */
case Company = 'company';
}

View file

@ -6,16 +6,25 @@ use App\Http\Controllers\Controller;
use App\Services\Web\NewsroomDetailPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
class NewsroomDetailController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View|Response
public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View|Response|RedirectResponse
{
$data = $pageData->build(WebPortalContext::fromRequest(), $slug);
// Firma in einer Zusammenführung aufgegangen → dauerhaft zum
// bleibenden Newsroom (Roadmap 1a.3).
// Host aus dem Request wie bei den anderen 301-Weiterleitungen in
// routes/web.php — route() allein trüge den konfigurierten Root-Host.
if (isset($data['redirect'])) {
return redirect(request()->getSchemeAndHttpHost().route('newsroom', ['slug' => $data['redirect']], absolute: false), 301);
}
// Bewusst entfernter Newsroom → Tombstone mit 410 Gone (ohne Inhalte).
// Vollständig gelöschte Firmen bleiben bei 404.
if (isset($data['tombstone'])) {

View file

@ -36,6 +36,36 @@ class Company extends Model
return 'company';
}
/**
* Portalübergreifende Firmen (`both`, seit der Zusammenführung 1a.3)
* sind auf JEDEM Portal erreichbar — die öffentliche Auflösung fragt
* `portal IN (eigenes, both)`. Ein Slug ist deshalb nur frei, wenn ihn
* weder eine Firma desselben Portals noch eine `both`-Firma trägt; und
* eine `both`-Firma braucht ihn auf allen Portalen frei. Der reine
* Portal-Scope des Traits sähe die Überschneidung nicht.
*
* @param array<string, mixed> $scope
*/
protected function slugExists(string $slug, array $scope): bool
{
$portal = array_key_exists('portal', $scope) ? $scope['portal'] : $this->getAttribute('portal');
$portal = $portal instanceof Portal ? $portal->value : $portal;
$query = static::query()
->withoutGlobalScopes()
->where($this->slugColumn(), $slug);
if ($this->exists) {
$query->whereKeyNot($this->getKey());
}
if ($portal !== null && $portal !== Portal::Both->value) {
$query->whereIn('portal', [$portal, Portal::Both->value]);
}
return $query->exists();
}
protected static function booted(): void
{
static::addGlobalScope(new PortalScope);

View file

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Enums\Portal;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Ein Zusammenführungsvorgang je Firmen-E-Mail (Roadmap 1a.3).
*
* Die Bewegungen (`moves`) sind die gespeicherte Ursprungszuordnung: Sie
* reichen aus, den Vorgang exakt umzukehren. `reverted_at` markiert, dass das
* geschehen ist — der Vorgang bleibt als Nachweis stehen.
*/
class CompanyMerge extends Model
{
protected $fillable = [
'batch',
'email',
'survivor_company_id',
'survivor_portal_before',
'merged_companies_count',
'moved_press_releases_count',
'reverted_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'survivor_portal_before' => Portal::class,
'reverted_at' => 'datetime',
];
}
/**
* @return BelongsTo<Company, $this>
*/
public function survivor(): BelongsTo
{
return $this->belongsTo(Company::class, 'survivor_company_id')->withTrashed();
}
/**
* @return HasMany<CompanyMergeMove, $this>
*/
public function moves(): HasMany
{
return $this->hasMany(CompanyMergeMove::class);
}
public function isReverted(): bool
{
return $this->reverted_at !== null;
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\CompanyMergeSubject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Ein verschobener Datensatz innerhalb einer Zusammenführung (Roadmap 1a.3).
* Nur über `CompanyMerge` sinnvoll; ohne Zeitstempel, weil der am Vorgang
* steht.
*/
class CompanyMergeMove extends Model
{
public $timestamps = false;
protected $fillable = [
'company_merge_id',
'subject',
'subject_id',
'from_company_id',
'field',
'previous_value',
'dropped',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'subject' => CompanyMergeSubject::class,
'dropped' => 'boolean',
];
}
/**
* @return BelongsTo<CompanyMerge, $this>
*/
public function merge(): BelongsTo
{
return $this->belongsTo(CompanyMerge::class, 'company_merge_id');
}
}

View 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,
) {}
}

View 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]);
}
}

View 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 = [],
) {}
}

View file

@ -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);
}