presseportale/app/Services/Company/CompanyMergeService.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

535 lines
22 KiB
PHP

<?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\Services\PressRelease\CanonicalAssigner;
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
&& $survivor->slugTakenOnOtherPortals($candidates->modelKeys())) {
$blocked[] = $survivor->slug;
continue;
}
$losers = $ranked->reject(fn (Company $company): bool => $company->is($survivor))->values();
return new CompanyMergePlan(
email: $group->email,
survivor: $survivor,
targetPortal: $targetPortal,
losers: $losers->all(),
excluded: $excluded->values()->all(),
pressReleases: $this->pressReleasesOf($losers->modelKeys())->count(),
);
}
return new CompanyMergeSkip($group->email, CompanyMergeSkip::SlugCollision, array_unique($blocked));
}
/**
* Führt den Plan aus. Alles in einer Transaktion je Adresse: Entweder die
* Firma ist zusammengeführt und jede Bewegung protokolliert, oder nichts
* ist passiert.
*
* @param bool $flushCaches false, wenn der Aufrufer viele Adressen nacheinander verarbeitet und einmal am Ende leert
*/
public function merge(CompanyMergePlan $plan, string $batch, bool $flushCaches = true): CompanyMerge
{
$merge = DB::transaction(function () use ($plan, $batch): CompanyMerge {
$survivor = $plan->survivor;
$merge = CompanyMerge::query()->create([
'batch' => $batch,
'email' => $plan->email,
'survivor_company_id' => $survivor->getKey(),
'survivor_portal_before' => $survivor->portal->value,
'merged_companies_count' => count($plan->losers),
'moved_press_releases_count' => 0,
]);
$moved = 0;
$fills = [];
foreach ($plan->losers as $loser) {
$moved += $this->moveReferences($merge, $loser, $survivor);
$this->moveUserLinks($merge, $loser, $survivor);
$this->movePaymentOptions($merge, $loser, $survivor);
$fills += $this->collectFills($merge, $loser, $survivor, array_keys($fills));
$this->record($merge, CompanyMergeSubject::Company, $loser->getKey(), $loser);
// Query-Builder statt $loser->delete(): Der Observer würde je
// Firma den Admin-Cache leeren, bei zehntausenden Firmen je
// Lauf. Der Aufrufer leert einmal am Ende.
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($loser->getKey())->update(['deleted_at' => now()]);
}
$survivorChanges = $fills;
if ($plan->targetPortal !== $survivor->portal) {
$survivorChanges['portal'] = $plan->targetPortal->value;
}
if (! $survivor->is_active && collect($plan->losers)->contains(fn (Company $loser): bool => (bool) $loser->is_active)) {
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0', merged: '1');
$survivorChanges['is_active'] = true;
}
if ($survivorChanges !== []) {
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivor->getKey())->update($survivorChanges);
}
$merge->update(['moved_press_releases_count' => $moved]);
$this->activityLog->record(
ActivityType::Correction,
sprintf('Newsroom „%s“: %d Firma/Firmen unter %s zusammengeführt, %d Meldungen übernommen', $survivor->name, count($plan->losers), $plan->email, $moved),
subject: $survivor,
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $batch],
actorLabel: 'companies:merge',
);
return $merge;
});
$this->reassignCanonicals($merge);
if ($flushCaches) {
$this->flushCaches();
}
return $merge;
}
/**
* Spielt die Bewegungen rückwärts ab. Was seit der Zusammenführung NEU
* beim Überlebenden entstanden ist, bleibt bei ihm — die Rückabwicklung
* kennt nur, was sie selbst verschoben hat.
*/
public function revert(CompanyMerge $merge, bool $flushCaches = true): void
{
if ($merge->isReverted()) {
return;
}
$merge->loadMissing('survivor');
DB::transaction(function () use ($merge): void {
$survivorId = $merge->survivor_company_id;
$moves = $merge->moves()->orderByDesc('id')->get();
foreach ($moves as $move) {
$this->undo($move, $survivorId);
}
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)
->update(['portal' => $merge->survivor_portal_before->value]);
$merge->update(['reverted_at' => now()]);
$this->activityLog->record(
ActivityType::Correction,
sprintf('Zusammenführung unter %s rückabgewickelt (%d Firmen, %d Meldungen)', $merge->email, $merge->merged_companies_count, $merge->moved_press_releases_count),
subject: $merge->survivor,
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $merge->batch],
actorLabel: 'companies:unmerge',
);
});
$this->reassignCanonicals($merge);
if ($flushCaches) {
$this->flushCaches();
}
}
/**
* Canonical-Zuordnung der verschobenen Meldungen neu bewerten (Umsetzungs-
* audit 29.09.2026, R-06): Nach dem Merge gehören Fassungen gleichen
* Inhalts auf beiden Portalen derselben Firma und werden zum Paar; nach
* dem Unmerge zeigt eine Kopie sonst auf die Meldung einer fremden Firma.
* Bewusst nach dem Commit — die Zuordnung ist ableitbar und lässt sich
* mit `press-releases:assign-canonicals` jederzeit nachziehen.
*/
private function reassignCanonicals(CompanyMerge $merge): void
{
$assigner = app(CanonicalAssigner::class);
if (! $assigner->enabled()) {
return;
}
$moves = $merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value);
$companyIds = $merge->moves()->distinct()->pluck('from_company_id')->push($merge->survivor_company_id)->unique()->values()->all();
$moves->select(['id', 'subject_id'])->chunkById(500, function ($chunk) use ($assigner, $companyIds): void {
$hashes = PressRelease::query()->withoutGlobalScopes()
->whereIn('id', $chunk->pluck('subject_id')->all())
->whereNotNull('content_hash')
->distinct()
->pluck('content_hash')
->all();
$assigner->reassignWithin($hashes, $companyIds);
});
}
public function flushCaches(): void
{
WebCaches::forgetPublicPages();
app(AdminPerformanceCache::class)->flush();
}
/**
* Alle lebenden Firmen unter dieser Adresse, portalübergreifend.
*
* @return Collection<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;
}
/**
* @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));
}
if ($subject === CompanyMergeSubject::PressRelease) {
// toBase(): ohne updated_at — der Inhalt der Meldung ändert
// sich nicht (Umsetzungsaudit 29.09.2026, R-02).
$query->whereIn('id', $ids)->toBase()->update(['company_id' => $survivor->getKey()]);
$moved = count($ids);
continue;
}
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
}
return $moved;
}
/**
* `company_user` als Vereinigung: Wer nur bei der wegfallenden Firma
* stand, wandert; wer schon beim Überlebenden steht, wird dort nicht
* doppelt — sein alter Eintrag fällt weg und wird als `dropped` mit Rolle
* protokolliert, damit die Rückabwicklung ihn neu anlegt.
*/
private function moveUserLinks(CompanyMerge $merge, Company $loser, Company $survivor): void
{
$existing = DB::table('company_user')->where('company_id', $survivor->getKey())->pluck('user_id')->flip();
foreach (DB::table('company_user')->where('company_id', $loser->getKey())->get() as $link) {
$dropped = isset($existing[$link->user_id]);
$this->record($merge, CompanyMergeSubject::CompanyUser, (int) $link->user_id, $loser, null, (string) $link->role, $dropped);
$query = DB::table('company_user')->where('company_id', $loser->getKey())->where('user_id', $link->user_id);
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
}
}
private function movePaymentOptions(CompanyMerge $merge, Company $loser, Company $survivor): void
{
$existing = DB::table('user_payment_option_company')->where('company_id', $survivor->getKey())->pluck('user_payment_option_id')->flip();
foreach (DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->get() as $link) {
$dropped = isset($existing[$link->user_payment_option_id]);
$this->record($merge, CompanyMergeSubject::PaymentOption, (int) $link->user_payment_option_id, $loser, null, (string) (int) $link->is_active, $dropped);
$query = DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->where('user_payment_option_id', $link->user_payment_option_id);
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
}
}
/**
* Leere Felder des Überlebenden aus dieser wegfallenden Firma. Erste
* Firma, die einen Wert hat, gewinnt — die Reihenfolge ist die Rangfolge
* aus Regel 1.
*
* @param list<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), merged: (string) $loser->getRawOriginal($field));
$fills[$field] = $loser->getRawOriginal($field);
}
return $fills;
}
private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false, ?string $merged = null): void
{
CompanyMergeMove::query()->create([
'company_merge_id' => $merge->getKey(),
'subject' => $subject,
'subject_id' => $subjectId,
'from_company_id' => $from->getKey(),
'field' => $field,
'previous_value' => $previous,
'merged_value' => $merged,
'dropped' => $dropped,
]);
}
private function undo(CompanyMergeMove $move, int $survivorId): void
{
$from = $move->from_company_id;
match ($move->subject) {
CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->toBase()->update(['company_id' => $from]),
CompanyMergeSubject::Contact => Contact::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
CompanyMergeSubject::AiDeclaration => PressReleaseAiDeclaration::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
CompanyMergeSubject::ConversionEvent => ConversionEvent::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
CompanyMergeSubject::CompanyUser => $this->undoPivot('company_user', 'user_id', $move, $survivorId, ['role' => $move->previous_value]),
CompanyMergeSubject::PaymentOption => $this->undoPivot('user_payment_option_company', 'user_payment_option_id', $move, $survivorId, ['is_active' => (bool) $move->previous_value]),
CompanyMergeSubject::Field => $this->undoField($move, $survivorId),
CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]),
};
}
/**
* Ein aufgefülltes Feld nur zurücksetzen, wenn es noch den beim Merge
* eingefüllten Wert trägt — hat der Kunde es inzwischen geändert (etwa
* nach dem Beanspruchen des Newsrooms), bleibt seine Fassung stehen
* (Umsetzungsaudit 29.09.2026, R-05). Zeilen von vor der Spalte
* `merged_value` vergleichen gegen die Herkunftsfirma: Die wurde beim
* Merge nur soft-gelöscht, ihr Wert ist der eingefüllte.
*/
private function undoField(CompanyMergeMove $move, int $survivorId): void
{
$field = (string) $move->field;
$filled = $move->merged_value;
if ($filled === null) {
$filled = $field === 'is_active'
? '1'
: Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($move->from_company_id)->toBase()->value($field);
}
$current = Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->toBase()->value($field);
if (! $this->sameRawValue($current, $filled)) {
return;
}
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([$field => $move->previous_value]);
}
private function sameRawValue(mixed $current, mixed $filled): bool
{
if (is_bool($current)) {
$current = (int) $current;
}
if (is_bool($filled)) {
$filled = (int) $filled;
}
return $current !== null && $filled !== null && (string) $current === (string) $filled;
}
/**
* @param array<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]);
}
}