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>
This commit is contained in:
parent
1ccb4a8479
commit
d6e49c11c3
48 changed files with 1676 additions and 134 deletions
|
|
@ -16,6 +16,7 @@ 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;
|
||||
|
|
@ -162,7 +163,7 @@ class CompanyMergeService
|
|||
}
|
||||
|
||||
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');
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0', merged: '1');
|
||||
$survivorChanges['is_active'] = true;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +184,8 @@ class CompanyMergeService
|
|||
return $merge;
|
||||
});
|
||||
|
||||
$this->reassignCanonicals($merge);
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
|
|
@ -225,11 +228,44 @@ class CompanyMergeService
|
|||
);
|
||||
});
|
||||
|
||||
$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();
|
||||
|
|
@ -341,11 +377,16 @@ class CompanyMergeService
|
|||
], $chunk));
|
||||
}
|
||||
|
||||
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
|
||||
|
||||
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;
|
||||
|
|
@ -400,14 +441,14 @@ class CompanyMergeService
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field));
|
||||
$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): void
|
||||
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(),
|
||||
|
|
@ -416,6 +457,7 @@ class CompanyMergeService
|
|||
'from_company_id' => $from->getKey(),
|
||||
'field' => $field,
|
||||
'previous_value' => $previous,
|
||||
'merged_value' => $merged,
|
||||
'dropped' => $dropped,
|
||||
]);
|
||||
}
|
||||
|
|
@ -425,17 +467,58 @@ class CompanyMergeService
|
|||
$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::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 => Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([(string) $move->field => $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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Builder;
|
|||
*/
|
||||
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.
|
||||
|
|
@ -34,9 +36,40 @@ class MergedNewsroomLookup
|
|||
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', $mergedIds)
|
||||
->whereIn('subject_id', $companyIds)
|
||||
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
||||
->with('merge:id,survivor_company_id')
|
||||
->latest('id')
|
||||
|
|
@ -44,10 +77,6 @@ class MergedNewsroomLookup
|
|||
?->merge
|
||||
?->survivor_company_id;
|
||||
|
||||
if ($survivorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Company::query()->withoutGlobalScope(PortalScope::class)->find($survivorId);
|
||||
return $survivorId !== null ? (int) $survivorId : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue