Kategorie-ID → Hauptkategorie (ID und DE-Slug) */ private array $categoryRoots = []; public function enabled(): bool { return (bool) config('canonical.enabled'); } /** * Portal, das für die Kategorie der Meldung die Erstquelle führt — null * ohne Eintrag in der Zuordnung. */ public function preferredPortal(PressRelease $pressRelease): ?Portal { if (! $pressRelease->category_id) { return null; } return $this->preferredPortalForCategory((int) $pressRelease->category_id); } /** * Zuordnung über die ID der Hauptkategorie (`portal_by_category_id`), * rückwärtskompatibel über den DE-Slug (`portal_by_category`) — eine * Slug-Änderung schaltet die Zuordnung damit nicht mehr still ab * (Umsetzungsaudit 29.09.2026, R-11). */ public function preferredPortalForCategory(int $categoryId): ?Portal { ['root' => $rootId, 'slug' => $slug] = $this->categoryRoot($categoryId); $byId = (array) config('canonical.portal_by_category_id', []); $portal = $rootId !== null ? ($byId[$rootId] ?? null) : null; $portal ??= $slug !== null ? config('canonical.portal_by_category.'.$slug) : null; return is_string($portal) ? Portal::tryFrom($portal) : null; } /** * IDs aller Kategorien (Haupt- und Unterkategorien), deren Erstquelle * `$portal` führt — für SQL-Auswertungen, die isCanonicalOn() für * portal=both nachbilden (IndexStatusReport). * * @return list */ public function categoryIdsPreferring(Portal $portal): array { return Category::query()->withoutGlobalScopes()->pluck('id') ->filter(fn (int $id): bool => $this->preferredPortalForCategory($id) === $portal) ->values() ->all(); } /** * Öffentliche Adresse, auf die rel=canonical der Meldung zeigt, wenn sie * auf `$viewedOn` ausgeliefert wird. Ohne Freigabe (config) oder ohne * Zuordnung: die eigene Adresse auf dem aufgerufenen Portal. */ public function canonicalUrlFor(PressRelease $pressRelease, Portal $viewedOn): string { $self = $this->publicUrl($pressRelease, $viewedOn); if (! $this->enabled()) { return $self; } if ($pressRelease->canonical_press_release_id !== null) { // Nur der PortalScope entfällt — eine soft-gelöschte Erstquelle // liefert 404 und darf kein Canonical-Ziel sein. $canonical = PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->canonical_press_release_id); if ($canonical !== null && $canonical->isVisibleInArchive()) { return $this->publicUrl($canonical, $canonical->portal === Portal::Both ? ($this->preferredPortal($canonical) ?? $viewedOn) : $canonical->portal); } return $self; } if ($pressRelease->portal === Portal::Both) { return $this->publicUrl($pressRelease, $this->preferredPortal($pressRelease) ?? $viewedOn); } return $self; } /** * Ob die Meldung auf `$portal` als Erstquelle gilt — nur die gehört in * Sitemap und News-Sitemap dieses Portals. */ public function isCanonicalOn(PressRelease $pressRelease, Portal $portal): bool { if (! $this->enabled()) { return true; } if ($pressRelease->canonical_press_release_id !== null) { return false; } if ($pressRelease->portal === Portal::Both) { return ($this->preferredPortal($pressRelease) ?? $portal) === $portal; } return true; } /** * Zuordnung für eine Meldung und ihre Gegenstücke auf dem anderen Portal * neu bestimmen — nach jeder Veröffentlichung. */ public function assignFor(PressRelease $pressRelease): void { if (! $this->enabled() || $pressRelease->content_hash === null || $pressRelease->portal === Portal::Both) { return; } $counterparts = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->published() ->where('content_hash', $pressRelease->content_hash) ->where('company_id', $pressRelease->company_id) ->whereKeyNot($pressRelease->getKey()) ->where('portal', '!=', $pressRelease->portal->value) ->get(); if ($counterparts->isEmpty()) { $this->write($pressRelease->getKey(), null); return; } foreach ($counterparts as $counterpart) { $this->assignPair($pressRelease, $counterpart); } } /** * Gegenstücke einer Erstquelle neu bewerten, nachdem diese aus der * öffentlichen Ansicht verschwunden ist (Depublikation, Gone, Löschung, * Statuswechsel). Jede Kopie verliert ihren Verweis und wird — falls ein * anderes veröffentlichtes Gegenstück existiert — neu zugeordnet; sonst * ist sie fortan selbst Erstquelle und gehört wieder in die Sitemap. */ public function reassignCopiesOf(PressRelease $former): void { if (! $this->enabled()) { return; } $copies = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->where('canonical_press_release_id', $former->getKey()) ->get(); foreach ($copies as $copy) { $this->write($copy->getKey(), null); $copy->canonical_press_release_id = null; $this->assignFor($copy); } } /** * Zuordnung aller Meldungen mit diesen Prüfsummen innerhalb dieser Firmen * neu bestimmen — nach Firmen-Merge und -Unmerge, die `company_id` am * Query-Builder vorbei umhängen (Umsetzungsaudit 29.09.2026, R-06). * Verweise, die kein gültiges Paar mehr bilden (andere Firma, anderer * Inhalt, Ziel nicht mehr veröffentlicht), fallen; danach wird jede * veröffentlichte Meldung wie nach einer Veröffentlichung zugeordnet. * * @param list $hashes * @param list $companyIds */ public function reassignWithin(array $hashes, array $companyIds): void { if (! $this->enabled() || $hashes === [] || $companyIds === []) { return; } foreach (array_chunk(array_values(array_unique($hashes)), 200) as $chunk) { $releases = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('content_hash', $chunk) ->whereIn('company_id', $companyIds) ->get(); foreach ($releases as $release) { if ($release->canonical_press_release_id === null) { continue; } $target = $releases->firstWhere('id', $release->canonical_press_release_id); if ($target === null || ! $this->formsPair($release, $target)) { $this->write($release->getKey(), null); $release->canonical_press_release_id = null; } } foreach ($releases as $release) { if ($this->isPairable($release)) { $this->assignFor($release); } } } } /** * Ob `$copy` zu Recht auf `$target` verweist — dieselben Bedingungen wie * pairsQuery(), nur für ein einzelnes Paar. */ private function formsPair(PressRelease $copy, PressRelease $target): bool { return $target->company_id === $copy->company_id && $target->content_hash !== null && $target->content_hash === $copy->content_hash && $target->portal !== $copy->portal && $target->portal !== Portal::Both && $copy->portal !== Portal::Both && $this->isPairable($target); } /** * Wie `published()` plus die Filter aus pairsQuery(). */ private function isPairable(PressRelease $pressRelease): bool { return $pressRelease->status === PressReleaseStatus::Published && $pressRelease->published_at !== null && $pressRelease->gone_at === null && ! $pressRelease->trashed(); } /** * Alle Paare im Bestand zuordnen. Vorher fallen Verweise, die kein Paar * aus pairsQuery() mehr bilden (Kopie zeigt auf eine gelöschte oder * nicht mehr veröffentlichte Meldung, auf eine andere Firma, einen * anderen Inhalt oder dasselbe Portal) — Umsetzungsaudit 29.09.2026, R-06. * * @param callable(int $canonicalId, int $duplicateId, bool $changed): void|null $each * @return array{pairs: int, changed: int, reset: int} */ public function assignAll(bool $dryRun = false, ?callable $each = null): array { $pairs = 0; $changed = 0; $reset = 0; // chunkById: Die Schreibvorgänge nehmen Zeilen aus der Ergebnismenge, // eine Offset-Paginierung würde dabei Zeilen überspringen. $this->staleReferencesQuery()->chunkById(500, function ($rows) use ($dryRun, &$reset): void { foreach ($rows as $row) { $reset++; if (! $dryRun) { $this->write((int) $row->id, null); } } }, 'p.id', 'id'); $this->pairsQuery()->orderBy('a.id')->chunk(500, function ($rows) use ($dryRun, $each, &$pairs, &$changed): void { $ids = $rows->flatMap(fn (object $row): array => [(int) $row->a_id, (int) $row->b_id])->unique()->all(); $releases = PressRelease::query()->withoutGlobalScopes()->findMany($ids)->keyBy('id'); foreach ($rows as $row) { $a = $releases->get((int) $row->a_id); $b = $releases->get((int) $row->b_id); if (! $a || ! $b) { continue; } $pairs++; $canonical = $this->resolveCanonical($a, $b); $duplicate = $canonical->is($a) ? $b : $a; $wouldChange = $duplicate->canonical_press_release_id !== $canonical->getKey() || $canonical->canonical_press_release_id !== null; if ($wouldChange) { $changed++; if (! $dryRun) { $this->write($duplicate->getKey(), $canonical->getKey()); $this->write($canonical->getKey(), null); } } if ($each !== null) { $each($canonical->getKey(), $duplicate->getKey(), $wouldChange); } } }); return ['pairs' => $pairs, 'changed' => $changed, 'reset' => $reset]; } /** * Meldungen mit einem Verweis, der kein gültiges Paar (mehr) ist. */ public function staleReferencesQuery(): QueryBuilder { return DB::table('press_releases as p') ->leftJoin('press_releases as c', 'c.id', '=', 'p.canonical_press_release_id') ->whereNotNull('p.canonical_press_release_id') ->where(fn (QueryBuilder $query) => $query ->whereNull('c.id') ->orWhereNotNull('c.deleted_at') ->orWhere('c.status', '!=', 'published') ->orWhereNotNull('c.gone_at') ->orWhere('c.portal', 'both') ->orWhere('p.portal', 'both') ->orWhereColumn('c.portal', '=', 'p.portal') ->orWhereNull('c.company_id') ->orWhereColumn('c.company_id', '!=', 'p.company_id') ->orWhereNull('c.content_hash') ->orWhereNull('p.content_hash') ->orWhereColumn('c.content_hash', '!=', 'p.content_hash')) ->select(['p.id']); } /** * Veröffentlichte Paare gleichen Inhalts derselben Firma auf verschiedenen * Portalen (a = kleinere ID). */ public function pairsQuery(): QueryBuilder { return DB::table('press_releases as a') ->join('press_releases as b', function ($join): void { $join->on('a.content_hash', '=', 'b.content_hash') ->on('a.company_id', '=', 'b.company_id') ->whereColumn('a.id', '<', 'b.id') ->whereColumn('a.portal', '!=', 'b.portal'); }) ->whereNotNull('a.content_hash') ->whereNull('a.deleted_at')->whereNull('b.deleted_at') ->where('a.status', 'published')->where('b.status', 'published') ->whereNull('a.gone_at')->whereNull('b.gone_at') ->whereNotIn('a.portal', ['both'])->whereNotIn('b.portal', ['both']) ->select(['a.id as a_id', 'b.id as b_id', 'a.category_id', 'a.company_id', 'a.published_at as a_published_at', 'b.published_at as b_published_at']); } /** * Welche der beiden Fassungen Erstquelle ist: die auf dem Kategorie- * Portal, sonst die zuerst veröffentlichte (bei Gleichstand die ältere ID). */ public function resolveCanonical(PressRelease $a, PressRelease $b): PressRelease { $preferred = $this->preferredPortal($a); if ($preferred !== null) { if ($a->portal === $preferred && $b->portal !== $preferred) { return $a; } if ($b->portal === $preferred && $a->portal !== $preferred) { return $b; } } $fallback = (string) config('canonical.fallback', 'first'); $fallbackPortal = Portal::tryFrom($fallback); if ($fallbackPortal !== null) { if ($a->portal === $fallbackPortal) { return $a; } if ($b->portal === $fallbackPortal) { return $b; } } $aAt = $a->published_at?->getTimestamp() ?? PHP_INT_MAX; $bAt = $b->published_at?->getTimestamp() ?? PHP_INT_MAX; if ($aAt !== $bAt) { return $aAt < $bAt ? $a : $b; } return $a->getKey() < $b->getKey() ? $a : $b; } private function assignPair(PressRelease $a, PressRelease $b): void { $canonical = $this->resolveCanonical($a, $b); $duplicate = $canonical->is($a) ? $b : $a; $this->write($duplicate->getKey(), $canonical->getKey()); $this->write($canonical->getKey(), null); } private function write(int $id, ?int $canonicalId): void { DB::table('press_releases') ->where('id', $id) ->where(fn ($query) => $canonicalId === null ? $query->whereNotNull('canonical_press_release_id') : $query->where('canonical_press_release_id', '!=', $canonicalId)->orWhereNull('canonical_press_release_id')) ->update(['canonical_press_release_id' => $canonicalId]); } private function publicUrl(PressRelease $pressRelease, Portal $portal): string { if ($portal === Portal::Both) { $portal = Portal::Presseecho; } $edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : 'de'; $base = rtrim((string) match ($portal) { Portal::Presseecho => config('domains.domain_presseecho_url'), default => config('domains.domain_businessportal_url'), }, '/'); return sprintf('%s/%s/press-release/%s', $base, $edition, $pressRelease->slug); } /** * @return array{root: int|null, slug: string|null} */ private function categoryRoot(int $categoryId): array { if (array_key_exists($categoryId, $this->categoryRoots)) { return $this->categoryRoots[$categoryId]; } $category = Category::query()->withoutGlobalScopes()->with(['parent.translations', 'translations'])->find($categoryId); $root = $category !== null && $category->parent !== null ? $category->parent : $category; $slug = $root?->translations->firstWhere('locale', 'de')?->slug; return $this->categoryRoots[$categoryId] = [ 'root' => $root?->getKey(), 'slug' => is_string($slug) ? $slug : null, ]; } }