diff --git a/app/Console/Commands/MergeCompanies.php b/app/Console/Commands/MergeCompanies.php new file mode 100644 index 00000000..6c197545 --- /dev/null +++ b/app/Console/Commands/MergeCompanies.php @@ -0,0 +1,178 @@ +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 + */ + 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 + */ + 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, + ]; + } +} diff --git a/app/Console/Commands/UnmergeCompanies.php b/app/Console/Commands/UnmergeCompanies.php new file mode 100644 index 00000000..4af3f908 --- /dev/null +++ b/app/Console/Commands/UnmergeCompanies.php @@ -0,0 +1,67 @@ +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; + } +} diff --git a/app/Enums/CompanyMergeSubject.php b/app/Enums/CompanyMergeSubject.php new file mode 100644 index 00000000..ea37fe7a --- /dev/null +++ b/app/Enums/CompanyMergeSubject.php @@ -0,0 +1,35 @@ +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'])) { diff --git a/app/Models/Company.php b/app/Models/Company.php index 824b3004..bb686110 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -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 $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); diff --git a/app/Models/CompanyMerge.php b/app/Models/CompanyMerge.php new file mode 100644 index 00000000..b9fa6ed1 --- /dev/null +++ b/app/Models/CompanyMerge.php @@ -0,0 +1,60 @@ + + */ + protected function casts(): array + { + return [ + 'survivor_portal_before' => Portal::class, + 'reverted_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function survivor(): BelongsTo + { + return $this->belongsTo(Company::class, 'survivor_company_id')->withTrashed(); + } + + /** + * @return HasMany + */ + public function moves(): HasMany + { + return $this->hasMany(CompanyMergeMove::class); + } + + public function isReverted(): bool + { + return $this->reverted_at !== null; + } +} diff --git a/app/Models/CompanyMergeMove.php b/app/Models/CompanyMergeMove.php new file mode 100644 index 00000000..8f157002 --- /dev/null +++ b/app/Models/CompanyMergeMove.php @@ -0,0 +1,46 @@ + + */ + protected function casts(): array + { + return [ + 'subject' => CompanyMergeSubject::class, + 'dropped' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function merge(): BelongsTo + { + return $this->belongsTo(CompanyMerge::class, 'company_merge_id'); + } +} diff --git a/app/Services/Company/CompanyMergePlan.php b/app/Services/Company/CompanyMergePlan.php new file mode 100644 index 00000000..0fe16d44 --- /dev/null +++ b/app/Services/Company/CompanyMergePlan.php @@ -0,0 +1,26 @@ + $losers die Firmen, die im Überlebenden aufgehen + * @param list $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, + ) {} +} diff --git a/app/Services/Company/CompanyMergeService.php b/app/Services/Company/CompanyMergeService.php new file mode 100644 index 00000000..b335147e --- /dev/null +++ b/app/Services/Company/CompanyMergeService.php @@ -0,0 +1,479 @@ + + */ + private const FillableFields = [ + 'website', 'phone', 'fax', 'address', 'country_code', 'logo_path', 'logo_variants', 'boilerplate', 'owner_user_id', + ]; + + public function __construct( + private readonly ActivityLog $activityLog, + ) {} + + /** + * Was für diese Adresse geschehen würde. Liest nur. + * + * @param Collection|null $members die Firmen der Adresse, wenn der Aufrufer sie schon geladen hat + */ + public function plan(CompanyEmailGroup $group, ?Collection $members = null): CompanyMergePlan|CompanyMergeSkip + { + if (! $group->isMergeable()) { + return new CompanyMergeSkip($group->email, CompanyMergeSkip::NotMergeable, [$group->effectiveClassification()->value]); + } + + $members ??= $this->membersOf($group->email); + + // 410-entfernte Firmen bleiben außen vor: Ihre Meldungen in den + // Überlebenden zu heben, würde bewusst entfernte Inhalte wieder + // sichtbar machen. + [$excluded, $candidates] = $members->partition(fn (Company $company): bool => $company->gone_at !== null); + + if ($candidates->count() < 2) { + return new CompanyMergeSkip($group->email, CompanyMergeSkip::NothingToMerge, $excluded->isNotEmpty() ? ['410-entfernt: '.$excluded->count()] : []); + } + + $ranked = $this->rankSurvivors($candidates); + $portals = $candidates->map(fn (Company $company): string => $company->portal->value)->unique()->values()->all(); + $blocked = []; + + foreach ($ranked as $survivor) { + $targetPortal = $this->targetPortal($survivor, $portals); + + if ($targetPortal === Portal::Both && $survivor->portal !== Portal::Both + && $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 + */ + public function membersOf(string $email): Collection + { + // Der Index auf `email` ist case-insensitiv (utf8mb4_unicode_ci); der + // PHP-Filter danach ist die Rückversicherung, dass genau die Firmen + // kommen, die die Klassifizierung unter diesem Schlüssel gezählt hat. + return Company::query() + ->withoutGlobalScope(PortalScope::class) + ->where('email', $email) + ->orderBy('id') + ->get() + ->filter(fn (Company $company): bool => Company::emailKey($company->email) === $email) + ->values(); + } + + /** + * Kandidaten nach Regel 1: meiste jemals veröffentlichte Meldungen, bei + * Gleichstand die ältere (kleinere ID). „Jemals" schließt Meldungen mit + * Aufrufen ein — der Altbestand trägt Hunderttausende offline genommene + * Meldungen unter `rejected`, die online waren. + * + * @param Collection $candidates + * @return Collection + */ + private function rankSurvivors(Collection $candidates): Collection + { + $published = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('company_id', $candidates->modelKeys()) + ->where(fn (Builder $query) => $query->where('status', 'published')->orWhere('hits', '>', 0)) + ->selectRaw('company_id, count(*) as n') + ->groupBy('company_id') + ->pluck('n', 'company_id'); + + return $candidates + ->sortBy([ + fn (Company $a, Company $b): int => ($published[$b->getKey()] ?? 0) <=> ($published[$a->getKey()] ?? 0), + fn (Company $a, Company $b): int => $a->getKey() <=> $b->getKey(), + ]) + ->values(); + } + + /** + * @param list $portals Portale aller Kandidaten + */ + private function targetPortal(Company $survivor, array $portals): Portal + { + if ($survivor->portal === Portal::Both || count($portals) > 1) { + return Portal::Both; + } + + return $survivor->portal; + } + + /** + * 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 $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 $companyIds + * @return Builder + */ + private function pressReleasesOf(array $companyIds): Builder + { + return PressRelease::query()->withoutGlobalScopes()->whereIn('company_id', $companyIds); + } + + /** + * Meldungen, Kontakte, KI-Erklärungen und Konversionsereignisse per + * `company_id` zum Überlebenden. Jede ID wird vorher protokolliert; das + * Update läuft über den Query-Builder (keine Model-Events — die + * Suchspiegelung enthält keine Firmendaten, ein Reindex ist unnötig). + * + * @return int verschobene Meldungen + */ + private function moveReferences(CompanyMerge $merge, Company $loser, Company $survivor): int + { + $moved = 0; + + $tables = [ + [CompanyMergeSubject::PressRelease, $this->pressReleasesOf([$loser->getKey()])], + [CompanyMergeSubject::Contact, Contact::query()->withoutGlobalScopes()->where('company_id', $loser->getKey())], + [CompanyMergeSubject::AiDeclaration, PressReleaseAiDeclaration::query()->where('company_id', $loser->getKey())], + [CompanyMergeSubject::ConversionEvent, ConversionEvent::query()->where('company_id', $loser->getKey())], + ]; + + foreach ($tables as [$subject, $query]) { + $ids = $query->pluck('id')->all(); + + if ($ids === []) { + continue; + } + + foreach (array_chunk($ids, 500) as $chunk) { + CompanyMergeMove::query()->insert(array_map(fn (int $id): array => [ + 'company_merge_id' => $merge->getKey(), + 'subject' => $subject->value, + 'subject_id' => $id, + 'from_company_id' => $loser->getKey(), + 'field' => null, + 'previous_value' => null, + 'dropped' => false, + ], $chunk)); + } + + $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 $alreadyFilled + * @return array + */ + private function collectFills(CompanyMerge $merge, Company $loser, Company $survivor, array $alreadyFilled): array + { + $fills = []; + + foreach (self::FillableFields as $field) { + if (in_array($field, $alreadyFilled, true) || ! blank($survivor->getRawOriginal($field)) || blank($loser->getRawOriginal($field))) { + continue; + } + + $this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field)); + $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 $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]); + } +} diff --git a/app/Services/Company/CompanyMergeSkip.php b/app/Services/Company/CompanyMergeSkip.php new file mode 100644 index 00000000..9d4c9f74 --- /dev/null +++ b/app/Services/Company/CompanyMergeSkip.php @@ -0,0 +1,26 @@ + $details + */ + public function __construct( + public string $email, + public string $reason, + public array $details = [], + ) {} +} diff --git a/app/Services/Web/NewsroomDetailPageData.php b/app/Services/Web/NewsroomDetailPageData.php index 198202cb..128a5627 100644 --- a/app/Services/Web/NewsroomDetailPageData.php +++ b/app/Services/Web/NewsroomDetailPageData.php @@ -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 $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 */ @@ -84,6 +130,12 @@ class NewsroomDetailPageData ->first(); if (! $company) { + $target = $this->mergedInto($portalValues, $slug); + + if ($target !== null) { + return ['redirect' => $target]; + } + abort(404); } diff --git a/database/migrations/2026_09_15_100000_create_company_merges_tables.php b/database/migrations/2026_09_15_100000_create_company_merges_tables.php new file mode 100644 index 00000000..5489536b --- /dev/null +++ b/database/migrations/2026_09_15_100000_create_company_merges_tables.php @@ -0,0 +1,56 @@ +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'); + } +}; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 54ba1112..8e8bede8 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -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 diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index dc67a41a..8dceb5e4 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -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. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a731c9d7..9d768cd6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/tests/Feature/CompanyMergeTest.php b/tests/Feature/CompanyMergeTest.php new file mode 100644 index 00000000..d1c14028 --- /dev/null +++ b/tests/Feature/CompanyMergeTest.php @@ -0,0 +1,325 @@ +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 $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(); +}); diff --git a/tests/Feature/HasUniqueSlugTest.php b/tests/Feature/HasUniqueSlugTest.php index a3cf205c..d07895eb 100644 --- a/tests/Feature/HasUniqueSlugTest.php +++ b/tests/Feature/HasUniqueSlugTest.php @@ -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('---', [