diff --git a/app/Console/Commands/FixLegacyTimestamps.php b/app/Console/Commands/FixLegacyTimestamps.php index 1c00e1d..97eb5be 100644 --- a/app/Console/Commands/FixLegacyTimestamps.php +++ b/app/Console/Commands/FixLegacyTimestamps.php @@ -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} "); } } diff --git a/app/Services/Import/PressReleaseImporter.php b/app/Services/Import/PressReleaseImporter.php index 5a4290c..b56bf18 100644 --- a/app/Services/Import/PressReleaseImporter.php +++ b/app/Services/Import/PressReleaseImporter.php @@ -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) { diff --git a/dev/migration 2026/MIGRATION-STEPS.md b/dev/migration 2026/MIGRATION-STEPS.md index 3d52b66..ae72a40 100644 --- a/dev/migration 2026/MIGRATION-STEPS.md +++ b/dev/migration 2026/MIGRATION-STEPS.md @@ -1,8 +1,29 @@ # Migration Steps – aktuelles Runbook -Stand: 2026-06-12. Dieses Kurz-Runbook spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`. +Stand: 2026-06-17. Spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`; die Live-Settings/Commands rund um den Go-Live in `../../docs/weiteres/Live-Deployment-Checkliste (WS-7).md`. -## Dry-Run +## Zwei-Phasen-Strategie (kurzer Ausfall) + +Der Import ist **wiederholbar**. Vorgesehener Ablauf: + +1. **Phase 1 – Voll-Import (jetzt):** Neues System auf `pressekonto.com` aufsetzen, Schema migrieren, kompletten Bestand mit `--force` importieren, Frontends auf Test-Domain verifizieren. +2. **Phase 2 – Delta-Lauf (beim Cutover):** Altsystem kurz einfrieren, dieselben Commands **ohne `--force`** erneut laufen lassen → bestehende Datensätze bleiben unangetastet, nur neue/zwischenzeitlich entstandene werden nachgezogen. Danach DNS umstellen. + +> **Die `--force`-Disziplin ist entscheidend:** +> - **MIT `--force`** (nur Phase 1): überschreibt bestehende Datensätze. +> - **OHNE `--force`** (Phase 2): überspringt bereits importierte Datensätze (`legacy_import_map`), importiert ausschließlich Neue. Genau das Verhalten für einen sauberen Delta-Lauf. + +--- + +## Phase 0 – Schema (einmalig, vor dem ersten Import) + +```bash +php artisan migrate --force +``` + +Ohne das Schema schlägt jeder Import-Schritt fehl. Auf dem Live-System ist das Teil der WS-7-Checkliste. + +## Dry-Run (vor jedem echten Lauf) ```bash php artisan legacy:import --source=all --dry-run @@ -10,12 +31,13 @@ php artisan legacy:archive-invoices --dry-run php artisan legacy:grandfather-subscriptions --dry-run php artisan legacy:verify --no-report php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration --dry-run - ``` Hinweis: `legacy:archive-invoices` importiert die Legacy-Rechnungen vollständig in `legacy_invoices`, inkl. Status/User-Zuordnung, `raw_snapshot`, `pdf_payload` und Report. Die PDF-Erzeugung erfolgt im Customer-Bereich bei Abruf aus diesen Archivdaten. -## Vollimport in korrekter Reihenfolge +--- + +## Phase 1 – Voll-Import (mit `--force`, korrekte Reihenfolge) ```bash php artisan legacy:import --source=presseecho --step=categories --force @@ -30,36 +52,68 @@ php artisan legacy:import --step=link-associations --force php artisan legacy:archive-invoices php artisan legacy:grandfather-subscriptions php artisan legacy:fix-timestamps +php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration php artisan legacy:verify ``` -Hinweis: `legacy:grandfather-subscriptions` läuft **nach** `legacy:archive-invoices`, -weil es die aktiven, jährlich wiederkehrenden Zahlungsvereinbarungen aus dem -Rechnungsarchiv ableitet (jüngste Rechnung pro Vereinbarung mit -`payment_option.type = recurring` und `user_payment_option.status = active`) -und als `grandfathered` in `user_payment_options` schreibt. Die nächste -Rechnung stellt danach der tägliche MAN-Kreis-Lauf -(`billing:generate-manual-invoices`) zum gewohnten Rhythmus aus. Re-Runs -aktualisieren bestehende Einträge (Replay-fähig für den Lauf kurz vor dem -Relaunch). Optionen: `--dry-run`, `--as-of=`, `--grace-months=12` (älter -überfällige Vereinbarungen gelten als stale und bleiben reines Archiv). +Reihenfolge ist wegen FKs Pflicht (companies vor contacts vor press-releases; users zuerst). Der Schritt `--step=users` importiert neben `sf_guard_user` auch `sf_guard_user_profile` in die neue Tabelle `profiles`. -Hinweis: Der Schritt `--step=users` importiert nicht nur `sf_guard_user`, sondern auch die direkt verknüpften Daten aus `sf_guard_user_profile` in die neue Tabelle `profiles`. +### Datums-Migration der Pressemitteilungen (wichtig für die Frontend-Sortierung) -## Alternativer Komplettlauf +Das Frontend sortiert/clustert nach `published_at`. Der Importer setzt daher: + +- `created_at`/`updated_at` = Legacy-`created_at`/`updated_at` (per `forceFill`, da nicht fillable) — das echte Anlage-/Bearbeitungsdatum, **nicht** das Importdatum. +- `published_at` = Legacy-`created_at` (Publikationsdatum) für veröffentlichte Meldungen. Bewusst **nicht** `updated_at` — dort steht bei vielen Alt-Datensätzen der Massen-/Migrationsstempel (z. B. `2026-04-…`), der sonst als „frisches" Veröffentlichungsdatum erschiene. + +`legacy:fix-timestamps` ist der schnelle Cross-DB-Resync (gleicher MySQL-Server) und korrigiert `created_at`, `updated_at` **und** `published_at` (für `status=published`) authoritativ. Bei einem reinen Delta-Lauf ohne Re-Import diesen Schritt mitlaufen lassen. + +--- + +## Phase 2 – Delta-Lauf beim Cutover (OHNE `--force`) + +Altsystem einfrieren, dann: ```bash -php artisan legacy:import --source=all --force +# Vorab sehen, was neu dazukommt +php artisan legacy:import --source=all --dry-run + +# Nur neue Datensätze nachziehen (kein --force!) +php artisan legacy:import --source=all php artisan legacy:archive-invoices -php artisan legacy:grandfather-subscriptions -php artisan legacy:fix-timestamps +php artisan legacy:grandfather-subscriptions # replay-fähig: aktualisiert bestehende, ergänzt neue +php artisan legacy:fix-timestamps # korrigiert created_at/updated_at/published_at php artisan legacy:verify -php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration + +# Medien NICHT mit --force – würde im neuen System gepflegte Logos/Bilder überschreiben. +# Nur falls neue Medien fehlen, gezielt ohne --force nachladen: +# php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration ``` -## Noch nicht im Runbook finalisiert +Danach DNS auf das neue System umstellen. `legacy:grandfather-subscriptions` läuft **nach** `legacy:archive-invoices`, weil es die aktiven, jährlich wiederkehrenden Zahlungsvereinbarungen aus dem Rechnungsarchiv ableitet (jüngste Rechnung pro Vereinbarung mit `payment_option.type = recurring` und `user_payment_option.status = active`) und als `grandfathered` in `user_payment_options` schreibt (Beträge netto in `legacy_conditions.net_cents`). Die nächste Rechnung stellt danach der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) zum gewohnten Rhythmus aus. Optionen: `--dry-run`, `--as-of=`, `--grace-months=12` (älter überfällige Vereinbarungen gelten als stale und bleiben reines Archiv). -- Medien-/Bilddateien-Transfer: Scope und finaler Command noch offen. +--- + +## Idempotenz / Replay-Sicherheit (Kurzreferenz) + +Alle Schritte schreiben per `updateOrCreate`/`insertOrIgnore` über `(legacy_portal, legacy_id)` bzw. `legacy_import_map`; **kein Truncate, kein Delete**. Re-Run-Verhalten: + +| Schritt | ohne `--force` | mit `--force` | zieht Neue nach | +|---|---|---|---| +| categories / users / companies / contacts | SKIP | UPDATE | ja | +| press-releases | SKIP | UPDATE (uuid + slug bleiben erhalten) | ja | +| link-associations | `insertOrIgnore` (idempotent) | — | ja | +| archive-invoices | SKIP | UPDATE | ja | +| grandfather-subscriptions | UPDATE/INSERT je Vereinbarung | — | ja | +| fix-timestamps | Raw-UPDATE (idempotent) | — | ja | +| migrate-media | SKIP (Pfad-Check) | **OVERWRITE** (Vorsicht) | ja | + +Vor Phase 2 immer `legacy:verify` laufen lassen, um Lücken aus Phase 1 (z. B. fehlgeschlagene User-Importe → verwaiste Rechnungen) zu erkennen. + +--- + +## Noch nicht finalisiert + +- Medien-/Bilddateien-Transfer: Scope/Command vorhanden (`legacy:migrate-media`), finale Base-Path-Strategie auf Live noch festzulegen. - Staging-Rehearsal mit aktuellem Produktiv-Snapshot bleibt Pflicht vor Go-Live. ## Legacy-Rechnungsreport @@ -70,11 +124,4 @@ php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migrati storage/app/private/migration/legacy-invoices-*.json ``` -Der Report enthält pro Portal: - -- Source-Count -- importierte/übersprungene/fehlerhafte Rechnungen -- Summe in Cent -- Statusverteilung -- Anzahl unzugeordneter Legacy-User -- Anzahl erzeugter PDF-Payloads \ No newline at end of file +Der Report enthält pro Portal: Source-Count, importierte/übersprungene/fehlerhafte Rechnungen, Summe in Cent, Statusverteilung, Anzahl unzugeordneter Legacy-User, Anzahl erzeugter PDF-Payloads. diff --git a/tests/Feature/LegacyPressReleaseDateImportTest.php b/tests/Feature/LegacyPressReleaseDateImportTest.php new file mode 100644 index 0000000..fa68685 --- /dev/null +++ b/tests/Feature/LegacyPressReleaseDateImportTest.php @@ -0,0 +1,180 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => false, + ]); + + DB::purge('mysql_presseecho'); + + Schema::connection('mysql_presseecho')->create('press_release', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('title')->nullable(); + $table->string('language')->nullable(); + $table->text('text')->nullable(); + $table->string('backlink_url')->nullable(); + $table->integer('category_id')->nullable(); + $table->string('keywords')->nullable(); + $table->integer('user_id')->nullable(); + $table->integer('company_id')->nullable(); + $table->string('status')->nullable(); + $table->integer('hits')->nullable(); + $table->integer('teaser_begin')->nullable(); + $table->integer('teaser_end')->nullable(); + $table->boolean('no_export')->nullable(); + $table->dateTime('created_at')->nullable(); + $table->dateTime('updated_at')->nullable(); + $table->string('slug')->nullable(); + }); + + Schema::connection('mysql_presseecho')->create('press_release_image', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->integer('press_release_id'); + }); + + Schema::connection('mysql_presseecho')->create('press_release_contact', function (Blueprint $table): void { + $table->integer('press_release_id'); + $table->integer('contact_id'); + }); + + $category = Category::factory()->withTranslations()->create(); + + LegacyImportMap::query()->create([ + 'legacy_portal' => 'presseecho', + 'legacy_table' => 'category', + 'legacy_id' => 500, + 'target_table' => 'categories', + 'target_id' => $category->id, + ]); + + $author = User::factory()->create(); + + LegacyImportMap::query()->create([ + 'legacy_portal' => 'presseecho', + 'legacy_table' => 'sf_guard_user', + 'legacy_id' => 700, + 'target_table' => 'users', + 'target_id' => $author->id, + ]); + + return $category; +} + +test('a published release takes published_at from the legacy created_at, not updated_at', function () { + /** @var TestCase $this */ + seedLegacyPressReleaseSchema(); + + // updated_at trägt im Altsystem den Massen-/Migrationsstempel (2026) – das + // darf NICHT als Veröffentlichungsdatum landen. + DB::connection('mysql_presseecho')->table('press_release')->insert([ + 'id' => 10, + 'title' => 'Alte veröffentlichte Meldung', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'published', + 'hits' => 5, + 'no_export' => false, + 'created_at' => '2008-11-18 12:05:42', + 'updated_at' => '2026-04-23 01:08:26', + 'slug' => 'alte-meldung', + ]); + + app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true)); + + $pr = PressRelease::withoutGlobalScopes() + ->where('legacy_portal', 'presseecho') + ->where('legacy_id', 10) + ->firstOrFail(); + + expect($pr->status)->toBe(PressReleaseStatus::Published); + expect($pr->published_at?->format('Y-m-d H:i:s'))->toBe('2008-11-18 12:05:42'); + expect($pr->created_at?->format('Y-m-d H:i:s'))->toBe('2008-11-18 12:05:42'); +}); + +test('a non-published release has no published_at and keeps its legacy created_at', function () { + /** @var TestCase $this */ + seedLegacyPressReleaseSchema(); + + DB::connection('mysql_presseecho')->table('press_release')->insert([ + 'id' => 11, + 'title' => 'Entwurf', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'new', + 'no_export' => false, + 'created_at' => '2010-03-01 09:00:00', + 'updated_at' => '2026-04-23 01:08:26', + 'slug' => 'entwurf', + ]); + + app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true)); + + $pr = PressRelease::withoutGlobalScopes() + ->where('legacy_portal', 'presseecho') + ->where('legacy_id', 11) + ->firstOrFail(); + + expect($pr->status)->toBe(PressReleaseStatus::Draft); + expect($pr->published_at)->toBeNull(); + expect($pr->created_at?->format('Y-m-d H:i:s'))->toBe('2010-03-01 09:00:00'); +}); + +test('a force re-import preserves the uuid and the publication date', function () { + /** @var TestCase $this */ + seedLegacyPressReleaseSchema(); + + DB::connection('mysql_presseecho')->table('press_release')->insert([ + 'id' => 12, + 'title' => 'Stabile Referenz', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'published', + 'no_export' => false, + 'created_at' => '2009-04-08 15:43:42', + 'updated_at' => '2026-04-21 22:07:40', + 'slug' => 'stabile-referenz', + ]); + + app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true)); + + $first = PressRelease::withoutGlobalScopes() + ->where('legacy_portal', 'presseecho')->where('legacy_id', 12)->firstOrFail(); + $originalUuid = $first->uuid; + + // Zweiter --force-Lauf (Delta-Szenario): darf uuid nicht neu würfeln. + app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true)); + + $second = PressRelease::withoutGlobalScopes() + ->where('legacy_portal', 'presseecho')->where('legacy_id', 12)->firstOrFail(); + + expect($second->uuid)->toBe($originalUuid); + expect($second->published_at?->format('Y-m-d H:i:s'))->toBe('2009-04-08 15:43:42'); +});