- 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>
82 lines
2.5 KiB
PHP
82 lines
2.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Company;
|
|
|
|
use App\Enums\CompanyMergeSubject;
|
|
use App\Models\Company;
|
|
use App\Models\CompanyMergeMove;
|
|
use App\Scopes\PortalScope;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
/**
|
|
* Wohin ein Newsroom-Slug führt, dessen Firma in einer Zusammenführung
|
|
* (Roadmap 1a.3) aufgegangen ist: zum Überlebenden. Gebraucht von der
|
|
* öffentlichen Newsroom-Seite (301) und vom Verwaltungszugang (1b.2) — beide
|
|
* müssen dieselbe Antwort geben.
|
|
*/
|
|
class MergedNewsroomLookup
|
|
{
|
|
private const MaxChainLength = 10;
|
|
|
|
/**
|
|
* Der Überlebende zu einem aufgegangenen Slug, oder null, wenn der Slug
|
|
* nie aufgegangen ist oder die Zusammenführung rückabgewickelt wurde.
|
|
*
|
|
* @param list<string> $portalValues
|
|
*/
|
|
public function survivorFor(array $portalValues, string $slug): ?Company
|
|
{
|
|
$mergedIds = Company::onlyTrashed()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->whereIn('portal', $portalValues)
|
|
->where('slug', $slug)
|
|
->pluck('id');
|
|
|
|
if ($mergedIds->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
$survivorId = $this->survivorIdOf($mergedIds->all());
|
|
$visited = [];
|
|
|
|
// Merge-Ketten (A → B, später B → C): Ist der Überlebende selbst
|
|
// aufgegangen, weiter bis zur lebenden Firma. Zyklenschutz über die
|
|
// besuchten IDs und eine feste Obergrenze (Umsetzungsaudit
|
|
// 29.09.2026, R-11).
|
|
while ($survivorId !== null && ! isset($visited[$survivorId]) && count($visited) < self::MaxChainLength) {
|
|
$visited[$survivorId] = true;
|
|
|
|
$survivor = Company::withTrashed()->withoutGlobalScope(PortalScope::class)->find($survivorId);
|
|
|
|
if ($survivor === null) {
|
|
return null;
|
|
}
|
|
|
|
if (! $survivor->trashed()) {
|
|
return $survivor;
|
|
}
|
|
|
|
$survivorId = $this->survivorIdOf([$survivorId]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $companyIds
|
|
*/
|
|
private function survivorIdOf(array $companyIds): ?int
|
|
{
|
|
$survivorId = CompanyMergeMove::query()
|
|
->where('subject', CompanyMergeSubject::Company->value)
|
|
->whereIn('subject_id', $companyIds)
|
|
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
|
->with('merge:id,survivor_company_id')
|
|
->latest('id')
|
|
->first()
|
|
?->merge
|
|
?->survivor_company_id;
|
|
|
|
return $survivorId !== null ? (int) $survivorId : null;
|
|
}
|
|
}
|