Migration: PM-Datum korrekt übernehmen, UUID stabil, Zwei-Phasen-Runbook

- published_at = Legacy-created_at (Publikationsdatum, Frontend-Sortier-
  schlüssel) statt updated_at, das bei Alt-Daten oft den Massen-/Migrations-
  stempel trägt und ein falsches "frisches" Datum erzeugte.
- created_at/updated_at per forceFill direkt im Import gesetzt (waren nicht
  fillable, wurden still verworfen) – Import allein ist jetzt datumssauber.
- legacy:fix-timestamps korrigiert zusätzlich published_at (status=published).
- PM-UUID bleibt beim Re-Import/Delta-Lauf erhalten (kein Neu-Würfeln).
- MIGRATION-STEPS auf Zwei-Phasen-Strategie umgestellt: migrate, Phase 1 mit
  --force, Phase 2 (Delta) ohne --force, Grandfather-Re-Run, Idempotenz-Tabelle.
- Tests: LegacyPressReleaseDateImportTest (published_at-Quelle, Entwurf,
  Force-Re-Import erhält UUID + Datum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 13:32:48 +00:00
parent a2ff47e44e
commit 5a9aab7012
4 changed files with 297 additions and 40 deletions

View file

@ -56,6 +56,9 @@ class FixLegacyTimestamps extends Command
'legacy_table_name' => 'press_release',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
// Veröffentlichte Meldungen: published_at = Legacy-created_at
// (Publikationsdatum, Sortier-/Cluster-Schlüssel im Frontend).
'derive_published_at' => true,
],
];
@ -111,6 +114,17 @@ class FixLegacyTimestamps extends Command
$legacyTable = $config['legacy_table_name'];
$legacyCreated = $config['legacy_created'];
$legacyUpdated = $config['legacy_updated'];
$derivePublishedAt = ! empty($config['derive_published_at']);
// Bei veröffentlichten Meldungen muss published_at dem Publikationsdatum
// (= Legacy-created_at) entsprechen; sonst sortiert/clustert das Frontend
// nach einem falschen Datum.
$publishedAtMismatch = $derivePublishedAt
? " OR (n.status = 'published' AND (n.published_at IS NULL OR n.published_at != lc.{$legacyCreated}))"
: '';
$publishedAtSet = $derivePublishedAt
? ", n.published_at = CASE WHEN n.status = 'published' THEN lc.{$legacyCreated} ELSE n.published_at END"
: '';
if ($isDryRun) {
// Nur zählen: wie viele Datensätze hätten falsche Timestamps?
@ -123,7 +137,7 @@ class FixLegacyTimestamps extends Command
AND m.legacy_portal = '{$portal}'
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
WHERE n.created_at != lc.{$legacyCreated}
OR n.updated_at != lc.{$legacyUpdated}
OR n.updated_at != lc.{$legacyUpdated}{$publishedAtMismatch}
")->cnt ?? 0;
}
@ -136,7 +150,7 @@ class FixLegacyTimestamps extends Command
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
SET
n.created_at = lc.{$legacyCreated},
n.updated_at = lc.{$legacyUpdated}
n.updated_at = lc.{$legacyUpdated}{$publishedAtSet}
");
}
}

View file

@ -139,12 +139,14 @@ class PressReleaseImporter
$status = self::STATUS_MAP[$row->status] ?? PressReleaseStatus::Draft;
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
// Beim Update (--force): bestehenden Slug behalten, um Kollisionen zu vermeiden.
// Beim Update (--force): bestehende uuid + slug behalten öffentliche
// Referenzen (API-uuid, slug-URLs) dürfen sich beim Re-Import/Delta-Lauf
// nicht ändern.
$existingPr = $alreadyImported
? PressRelease::withoutGlobalScopes()
->where('legacy_portal', $legacyPortal)
->where('legacy_id', $row->id)
->first(['id', 'slug'])
->first(['id', 'uuid', 'slug'])
: null;
$slug = $existingPr?->slug ?? $this->uniqueSlug(
@ -154,18 +156,23 @@ class PressReleaseImporter
$existingPr?->id,
);
// Veröffentlichungsdatum = Legacy-`created_at` (Anlage-/Publikationsdatum,
// im Altsystem der indexierte Sortierschlüssel). Bewusst NICHT `updated_at`:
// dort steht der letzte Bearbeitungs-/Massen-Update-Stempel (bei vielen
// Alt-Datensätzen das Migrationsdatum), was im Frontend das nach
// `published_at` sortiert/clustert ein falsches, "frisches" Datum erzeugt.
$publishedAt = ($status === PressReleaseStatus::Published)
? ($row->updated_at ?? $row->created_at)
? $row->created_at
: null;
$pr = PressRelease::withoutTimestamps(function () use (
$legacyPortal, $row, $portal, $userId, $companyId, $categoryId,
$language, $slug, $status, $publishedAt,
$language, $slug, $status, $publishedAt, $existingPr,
): PressRelease {
return PressRelease::withoutGlobalScopes()->updateOrCreate(
$pr = PressRelease::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
[
'uuid' => (string) Str::uuid(),
'uuid' => $existingPr?->uuid ?? (string) Str::uuid(),
'portal' => $portal->value,
'user_id' => $userId,
'company_id' => $companyId,
@ -182,10 +189,19 @@ class PressReleaseImporter
'teaser_end' => max(0, (int) ($row->teaser_end ?? 0)),
'no_export' => (bool) ($row->no_export ?? false),
'published_at' => $publishedAt,
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
]
);
// created_at/updated_at sind nicht fillable und werden von
// updateOrCreate verworfen → direkt setzen, damit das Anlage-/
// Bearbeitungsdatum bereits durch den Import korrekt migriert ist
// (legacy:fix-timestamps bleibt der schnelle Cross-DB-Resync).
$pr->forceFill([
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
])->save();
return $pr;
});
foreach ($images->get($row->id, []) as $img) {