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

View file

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Protokoll der Newsroom-Zusammenführung (Roadmap 1a.3).
*
* `company_merges` ist ein Vorgang je Adresse: welche Firma geblieben ist und
* welches Portal sie vorher trug. `company_merge_moves` hält je verschobenem
* Datensatz fest, woher er kam — das ist die gespeicherte Ursprungszuordnung,
* über die `companies:unmerge` den Vorgang exakt rückabwickelt.
*
* Bewusst zwei eigene Tabellen statt einer Spalte `merged_from_company_id` an
* `press_releases`: Die ist über 1 GB groß, ein ADD COLUMN dort nur für einen
* einmaligen Vorgang lohnt nicht, und Kontakte, Nutzer-Verknüpfungen und
* Zahlungsoptionen bräuchten dieselbe Spalte noch einmal. Hier landen alle in
* einer Liste.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('company_merges', function (Blueprint $table) {
$table->id();
$table->string('batch', 36)->index();
$table->string('email', 190)->index();
$table->foreignId('survivor_company_id')->constrained('companies')->restrictOnDelete();
$table->string('survivor_portal_before', 32);
$table->unsignedInteger('merged_companies_count');
$table->unsignedInteger('moved_press_releases_count');
$table->timestamp('reverted_at')->nullable();
$table->timestamps();
});
Schema::create('company_merge_moves', function (Blueprint $table) {
$table->id();
$table->foreignId('company_merge_id')->constrained('company_merges')->cascadeOnDelete();
$table->string('subject', 32);
$table->unsignedBigInteger('subject_id')->nullable();
$table->foreignId('from_company_id')->constrained('companies')->restrictOnDelete();
$table->string('field', 64)->nullable();
$table->text('previous_value')->nullable();
$table->boolean('dropped')->default(false);
$table->index(['subject', 'subject_id'], 'company_merge_moves_subject_idx');
});
}
public function down(): void
{
Schema::dropIfExists('company_merge_moves');
Schema::dropIfExists('company_merges');
}
};

View file

@ -108,6 +108,8 @@ Der Kern. Wandelt vorhandenen Zulauf in Konten und Kunden, ohne neuen Traffic zu
Schlüssel ist die **Firmen-E-Mail**, nicht die Pressekontaktadresse. Der Pressekontakt ist der Firma untergeordnet und wechselt; die Firmenidentität darf nicht an einer Person hängen. Der Magic Link kann trotzdem an beide gehen, das ist eine Berechtigungs- und keine Identitätsfrage.
**Entschieden 15.09.2026 (1a.3):** Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen, bei Gleichstand die ältere; leere Stammdaten werden aufgefüllt. Meldungen, Kontakte und Verknüpfungen wandern per `company_id`, jede Bewegung protokolliert (`company_merges`/`company_merge_moves`), rückabwickelbar mit `companies:unmerge`. Die wegfallende Firma wird soft-deleted, ihr Slug führt per 301 weiter. Der Überlebende wird `both`, wenn die Hälften auf verschiedenen Portalen lagen — das ist bei 77 % der Gruppen der Fall (Altsystem: eine Firma je Portal). Slug-Kollisionen werden übersprungen, nicht umbenannt. Details in `umsetzung-protokoll.md` Teil 3.
Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die rückwirkende Zusammenführung im Altbestand trägt sie das Portal, weil dort nicht angelegt, sondern verschmolzen wird. Deshalb 1a.1 vor 1a.3.
### 1b Newsroom als Produkt

View file

@ -701,3 +701,104 @@ Statuslogs des neuen Systems).
**Deploy:** Nach dem Deploy `companies:classify-addresses` erneut laufen
lassen — die 16 Adressen werden dabei umklassifiziert, Handentscheidungen
bleiben.
## 1a.3 — Zusammenführung im Bestand
```
php artisan companies:merge [--dry-run] [--limit=] [--email=] [--json]
php artisan companies:unmerge --merge=ID | --batch=UUID | --email= [--dry-run]
```
### Der Fakt, der den Entwurf bestimmt hat
Die meisten „Dubletten" sind **dieselbe Firma auf zwei Portalen**: Das
Altsystem hat jede Firma je Portal einmal angelegt.
| | Gruppen |
|---|---:|
| Adressgruppen gesamt (Dev-DB) | 18 860 |
| davon mit Firmen auf beiden Portalen | **14 519** (77 %) |
| Zweiergruppen | 15 754 |
| davon exakt PE + B24 | 12 605 |
| davon zusätzlich mit identischem Slug | 12 037 |
`Portal::Both` existiert im Code — Newsroom-Controller, PortalScope und
Slug-Auflösung behandeln es —, aber **keine Firma** trug es. Die
Zusammenführung ist also weniger „Dubletten löschen" als „zwei Portalhälften
zu einer Firma machen": Der Überlebende bekommt `both`, die Meldungen
behalten ihr Portal, der Newsroom zeigt auf jedem Portal die Meldungen dieses
Portals.
### Entscheidungen (15.09.2026, A/A/A + both)
1. **Es bleibt** die Firma mit den meisten jemals veröffentlichten Meldungen
(`published` oder `hits > 0`), bei Gleichstand die ältere. Leere
Stammdatenfelder (Website, Telefon, Fax, Adresse, Land, Logo, Boilerplate,
Inhaber) füllt sie aus den wegfallenden Firmen; belegte bleiben. Ist der
Überlebende inaktiv und eine wegfallende Firma aktiv, wird er aktiv.
2. **Meldungen, Kontakte, KI-Erklärungen, Konversionsereignisse** wandern per
`company_id`. `company_user` und Zahlungsoptionen werden zur Vereinigung;
ein Nutzer, der schon am Überlebenden stand, wird nicht doppelt — sein
alter Eintrag fällt weg und ist als `dropped` mit Rolle protokolliert.
Jede Bewegung steht in `company_merge_moves`; der Vorgang in
`company_merges` (Batch-ID, Überlebender, sein Portal vorher).
3. **Die wegfallende Firma** wird soft-deleted. Ihr alter Slug führt per
**301** zum Überlebenden (`NewsroomDetailPageData::mergedInto()`), sofern
der Slug abweicht — bei gleichem Slug findet die Auflösung den `both`-
Überlebenden direkt. Kein 410: Die Firma gibt es noch, nur woanders.
**Slug-Kollision:** Wird der Überlebende `both`, muss sein Slug auf dem
anderen Portal frei sein (die öffentliche Auflösung fragt
`portal IN (eigenes, both)`). Ist der Erstkandidat blockiert, kommt der
nächste dran; ist keiner frei, wird die Adresse **übersprungen** und
aufgelistet. Nicht umbenannt — die URL ist öffentlich. Vorab geschätzt 664
Gruppen, tatsächlich **5** — die Kollisionen sind fast immer einseitig
(Alt-Slugs sind je Portal durchnummeriert), und die andere Hälfte ist frei.
**410-entfernte Firmen** bleiben außen vor: Ihre Meldungen in den
Überlebenden zu heben, würde bewusst entfernte Inhalte wieder sichtbar
machen.
**Keine Schema-Änderung an bestehenden Tabellen.** Kein
`merged_from_company_id` auf `press_releases` (1 GB, einmaliger Vorgang),
kein `merged_into_company_id` auf `companies` (FULLTEXT) — beides kommt aus
den zwei neuen Tabellen. `press_release_search` enthält keine Firmendaten;
das Umhängen per Query-Builder braucht keinen Reindex.
### Nebenbefund und Fix: Slug-Eindeutigkeit kannte `both` nicht
`HasUniqueSlug` prüft bei Firmen nur `portal = X`. Eine neue PE-Firma hätte
denselben Slug wie eine `both`-Firma bekommen können — und die Newsroom-URL
hätte zwei Firmen gefunden. `Company::slugExists()` prüft jetzt
`portal IN (X, both)`, und eine `both`-Firma braucht den Slug überall frei.
Test in `HasUniqueSlugTest`.
### Lauf auf der Dev-DB (Kopie des Bestands)
| | |
|---|---:|
| Adressen zusammengeführt | 14 142 |
| Firmen im Überlebenden aufgegangen | 16 194 |
| Meldungen gewandert | 13 297 |
| Firmen jetzt `both` | 12 926 |
| Kontakte gewandert | 8 038 |
| Nutzer-Verknüpfungen weggefallen (schon am Überlebenden) | 15 467 |
| Stammdatenfelder aufgefüllt | 1 819 |
| übersprungen (Slug-Kollision) | 5 |
| Laufzeit merge / unmerge | 2:00 min / 1:15 min |
Nach `companies:unmerge --batch=…` stimmten Portalverteilung (42 287 / 66 892),
`company_user` (107 663) und Soft-Deletes (2 155) wieder exakt mit dem
Stand davor überein. Die Rückabwicklung ist verifiziert.
Zwei Performance-Punkte beim Bau: Die Kollisionsprüfung mit `portal != ?`
kostete 24 ms je Adresse (kein Index) — mit `portal IN (…)` greift der
Unique-Index (portal, slug), 0 ms. Und wegfallende Firmen werden per
Query-Builder soft-deleted, nicht per `$company->delete()`: Der Observer
würde je Firma den Admin-Cache leeren; der Lauf leert einmal am Ende.
**Deploy:** `migrate --force`, dann `companies:merge --dry-run`, dann
`companies:merge --limit=50` und die 50 im Admin ansehen, dann ohne Limit.
Erledigte Adressen zählen beim zweiten Lauf nicht gegen das Limit. Danach
`companies:classify-addresses` — die zusammengeführten Adressen fallen aus
`company_email_groups`, weil nur noch eine Firma darunter steht.

View file

@ -636,12 +636,6 @@ parameters:
count: 1
path: app/Models/Category.php
-
message: '#^PHPDoc tag @var with type Illuminate\\Database\\Eloquent\\Model is not subtype of native type \$this\(App\\Models\\Company\)\.$#'
identifier: varTag.nativeType
count: 1
path: app/Models/Company.php
-
message: '#^Using nullsafe property access on non\-nullable type App\\Enums\\Portal\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull

View file

@ -0,0 +1,325 @@
<?php
use App\Enums\CompanyAddressClass;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\CompanyMerge;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Zusammenführung im Bestand (Roadmap 1a.3): rückabwickelbar, mit
* gespeicherter Ursprungszuordnung. Entschieden am 15.09.2026: Es bleibt die
* Firma mit den meisten Meldungen, alles wandert per company_id, die
* wegfallende Firma wird soft-deleted und ihr Slug führt per 301 weiter.
*/
function mergeGroup(string $email, CompanyAddressClass $class = CompanyAddressClass::Duplicate, ?CompanyAddressClass $manual = null): CompanyEmailGroup
{
return CompanyEmailGroup::query()->create([
'email' => Company::emailKey($email),
'classification' => $class,
'manual_classification' => $manual,
'companies_count' => 2,
'distinct_names' => 1,
'published_companies_count' => 1,
'threshold' => 3,
'classified_at' => now(),
]);
}
/**
* @param array<string, mixed> $attributes
*/
function mergeMember(string $email, Portal $portal, int $published = 0, array $attributes = []): Company
{
$company = Company::factory()->create(['email' => $email, 'portal' => $portal->value, 'name' => 'Muster GmbH'] + $attributes);
PressRelease::factory()->published()->forPortal($portal)->count($published)->create(['company_id' => $company->id]);
return $company;
}
function planFor(string $email): CompanyMergePlan|CompanyMergeSkip
{
return app(CompanyMergeService::class)->plan(CompanyEmailGroup::forEmail($email));
}
test('the company with more publications survives and becomes both when halves lie on different portals', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$pe = mergeMember('info@muster.de', Portal::Presseecho, 2);
$b24 = mergeMember('info@muster.de', Portal::Businessportal24, 5);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($b24))->toBeTrue()
->and($plan->targetPortal)->toBe(Portal::Both)
->and($plan->pressReleases)->toBe(2);
$merge = app(CompanyMergeService::class)->merge($plan, 'test-batch');
// Die Meldungen behalten ihr Portal — nur die Firma wird portalübergreifend.
expect($b24->fresh()->portal)->toBe(Portal::Both)
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->count())->toBe(7)
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->where('portal', 'presseecho')->count())->toBe(2)
->and(Company::withTrashed()->find($pe->id)->trashed())->toBeTrue()
->and($merge->moved_press_releases_count)->toBe(2)
->and($merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value)->count())->toBe(2)
->and($merge->moves()->where('subject', CompanyMergeSubject::Company->value)->value('subject_id'))->toBe($pe->id);
});
test('on a tie the older company survives', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$older = mergeMember('info@muster.de', Portal::Presseecho, 1);
mergeMember('info@muster.de', Portal::Presseecho, 1);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($older))->toBeTrue()
->and($plan->targetPortal)->toBe(Portal::Presseecho);
});
test('releases taken offline in the legacy system count for the survivor choice', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$fresh = mergeMember('info@muster.de', Portal::Presseecho, 1);
$legacy = mergeMember('info@muster.de', Portal::Businessportal24);
PressRelease::factory()->count(3)->create(['company_id' => $legacy->id, 'status' => 'rejected', 'hits' => 50]);
expect(planFor('info@muster.de')->survivor->is($legacy))->toBeTrue();
});
test('empty profile fields are filled from the merged company, filled ones stay', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'phone' => '030 1', 'logo_path' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'phone' => '030 2', 'logo_path' => 'logos/muster.png']);
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
$survivor->refresh();
expect($survivor->website)->toBe('https://muster.de')
->and($survivor->logo_path)->toBe('logos/muster.png')
->and($survivor->phone)->toBe('030 1')
->and($merge->moves()->where('subject', CompanyMergeSubject::Field->value)->pluck('field')->sort()->values()->all())->toBe(['logo_path', 'website']);
});
test('user links become the union without duplicates and contacts move along', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1);
$loser = mergeMember('info@muster.de', Portal::Businessportal24);
$shared = User::factory()->create();
$only = User::factory()->create();
DB::table('company_user')->insert([
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
['company_id' => $loser->id, 'user_id' => $only->id, 'role' => 'member'],
]);
$contact = Contact::factory()->create(['company_id' => $loser->id]);
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
expect(DB::table('company_user')->where('company_id', $survivor->id)->pluck('user_id')->sort()->values()->all())->toBe(collect([$shared->id, $only->id])->sort()->values()->all())
->and(DB::table('company_user')->where('company_id', $loser->id)->count())->toBe(0)
->and($contact->fresh()->company_id)->toBe($survivor->id)
->and($merge->moves()->where('subject', CompanyMergeSubject::CompanyUser->value)->where('dropped', true)->value('subject_id'))->toBe($shared->id);
});
test('unmerge restores exactly what was moved and leaves what came later', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1, ['website' => null]);
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['website' => 'https://muster.de']);
$loserReleases = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->pluck('id');
$shared = User::factory()->create();
DB::table('company_user')->insert([
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
]);
$service = app(CompanyMergeService::class);
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
// Nach der Zusammenführung entsteht eine neue Meldung beim Überlebenden.
$later = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $survivor->id]);
$service->revert($merge);
$survivor->refresh();
expect($survivor->portal)->toBe(Portal::Presseecho)
->and($survivor->website)->toBeNull()
->and(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse()
->and(PressRelease::withoutGlobalScopes()->whereIn('id', $loserReleases)->pluck('company_id')->unique()->all())->toBe([$loser->id])
->and($later->fresh()->company_id)->toBe($survivor->id)
->and(DB::table('company_user')->where('company_id', $loser->id)->where('user_id', $shared->id)->value('role'))->toBe('member')
->and(DB::table('company_user')->where('company_id', $survivor->id)->count())->toBe(1)
->and($merge->fresh()->isReverted())->toBeTrue();
// Ein zweites Mal tut nichts.
$service->revert($merge->fresh());
expect(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse();
});
test('a slug taken by a foreign company on the other portal skips the address', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
// Beide Hälften tragen verschiedene Slugs, und jeder ist auf dem jeweils
// anderen Portal von einer fremden Firma belegt — keiner kann `both` werden.
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster-a']);
mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-b']);
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster-a']);
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'presseecho', 'slug' => 'muster-b']);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
->and($plan->reason)->toBe(CompanyMergeSkip::SlugCollision)
->and(Company::withoutGlobalScopes()->where('email', 'info@muster.de')->count())->toBe(2);
});
test('when the best candidate collides but another does not, the other survives', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster']);
$free = mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-gmbh']);
// „muster" ist auf B24 fremd belegt → die PE-Hälfte kann nicht `both` werden,
// die B24-Hälfte mit „muster-gmbh" schon.
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster']);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($free))->toBeTrue();
});
test('only mergeable classes are touched and a manual decision overrides', function () {
/** @var TestCase $this */
mergeGroup('agentur@sammel.de', CompanyAddressClass::CollectionAddress);
mergeGroup('doch@agentur.de', CompanyAddressClass::CollectionAddress, manual: CompanyAddressClass::Duplicate);
mergeGroup('nicht@dublette.de', CompanyAddressClass::Duplicate, manual: CompanyAddressClass::CollectionAddress);
foreach (['agentur@sammel.de', 'doch@agentur.de', 'nicht@dublette.de'] as $email) {
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge')->assertSuccessful();
expect(CompanyMerge::query()->pluck('email')->all())->toBe(['doch@agentur.de'])
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'agentur@sammel.de')->count())->toBe(2)
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'nicht@dublette.de')->count())->toBe(2);
});
test('companies removed with 410 stay out of the merge', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 1);
$gone = mergeMember('info@muster.de', Portal::Businessportal24, 4, ['gone_at' => now()]);
$plan = planFor('info@muster.de');
// Nur eine lebende Firma übrig → nichts zu tun; die entfernte bleibt entfernt.
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
->and($plan->reason)->toBe(CompanyMergeSkip::NothingToMerge)
->and($gone->fresh()->gone_at)->not->toBeNull();
});
test('dry run and limit write nothing and count correctly', function () {
/** @var TestCase $this */
foreach (['a@muster.de', 'b@muster.de', 'c@muster.de'] as $email) {
mergeGroup($email);
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 3 Adressen')
->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(0)
->and(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0);
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(2)
->and(CompanyMerge::query()->distinct()->count('batch'))->toBe(1);
// Der zweite Lauf holt den Rest — die erledigten Adressen zählen nicht
// gegen das Limit.
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(3);
});
test('unmerge by batch reverts every merge of that run', function () {
/** @var TestCase $this */
foreach (['a@muster.de', 'b@muster.de'] as $email) {
mergeGroup($email);
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge')->assertSuccessful();
$batch = CompanyMerge::query()->value('batch');
$this->artisan('companies:unmerge', ['--batch' => $batch])
->expectsOutputToContain('2 Vorgänge rückabgewickelt')
->assertSuccessful();
expect(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0)
->and(CompanyMerge::query()->whereNull('reverted_at')->count())->toBe(0)
->and(Company::withoutGlobalScopes()->where('portal', 'both')->count())->toBe(0);
});
test('the old newsroom slug redirects permanently to the survivor', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->first();
$release->update(['published_at' => now()->subDay()]);
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$service = app(CompanyMergeService::class);
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
// Alte URL → 301 auf den Überlebenden, der auf diesem Portal über `both`
// erreichbar ist und die Meldung dieses Portals zeigt.
$this->get('https://presseecho.test/de/newsroom/muster')
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh')
->assertStatus(301);
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertOk()->assertSeeText($release->title);
// Nach der Rückabwicklung antwortet die alte URL wieder selbst.
$service->revert($merge);
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertNotFound();
});
test('the same slug on both portals needs no redirect', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster']);
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->update(['published_at' => now()->subDay()]);
$service = app(CompanyMergeService::class);
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$this->get('https://businessportal24.test/de/newsroom/muster')->assertOk();
});

View file

@ -97,6 +97,21 @@ test('company slug is unique within portal scope', function () {
expect($other)->toBe('acme-gmbh');
});
test('a cross-portal company blocks its slug on every portal and needs it free everywhere', function () {
/** @var TestCase $this */
// Seit der Zusammenführung (Roadmap 1a.3) gibt es Firmen mit `both`; die
// öffentliche Auflösung fragt `portal IN (eigenes, both)`. Ein neuer
// PE-Slug darf deshalb nicht mit einer both-Firma zusammenfallen — sonst
// fände die Newsroom-URL zwei Firmen.
Company::factory()->create(['portal' => Portal::Both->value, 'slug' => 'acme-gmbh']);
Company::factory()->create(['portal' => Portal::Presseecho->value, 'slug' => 'beta-gmbh']);
expect((new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Presseecho->value]))->toBe('acme-gmbh-2')
->and((new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Businessportal24->value]))->toBe('acme-gmbh-2')
->and((new Company)->generateUniqueSlug('Beta GmbH', ['portal' => Portal::Both->value]))->toBe('beta-gmbh-2')
->and((new Company)->generateUniqueSlug('Beta GmbH', ['portal' => Portal::Businessportal24->value]))->toBe('beta-gmbh');
});
test('blank source falls back to model defined fallback', function () {
/** @var TestCase $this */
$prSlug = (new PressRelease)->generateUniqueSlug('---', [