# 05 – Zusammenführung der zwei Legacy-Datenbanken Beide Legacy-Portale (`presseecho` und `businessportal24`) haben **strukturell nahezu identische** Datenbanken. Sie werden in **eine Ziel-DB** konsolidiert. ## Ist-Stand Dumps (2026-04-23) | Portal | Datei | Größe | Tabellen | |---|---|---|---| | businessportal24 | `dev/migration 2026/sql/businessportal24_2026-04-23.sql` | 578 MB | 42 | | presseecho | `dev/migration 2026/sql/presseecho_2026-04-23.sql` | 369 MB | 43 | **Strukturelle Unterschiede** zwischen den beiden Dumps: - Nur **businessportal24** hat: `press_release_image_old` (legacy Altbestand – ignorierbar, nur Archivwert) - Nur **presseecho** hat: `category_pe_data`, `press_release_pe_data` (zusätzliche PE-spezifische Felder) Die 41 gemeinsamen Tabellen haben identische DDL – Import kann mit einem Code-Pfad laufen (Portal als Parameter). --- ## 1. Grundprinzipien 1. **Die Ziel-DB ist NEU.** Wir kopieren keine Tabellen 1:1. Stattdessen werden Daten transformiert eingespielt (siehe [`04-DATA-MODEL.md`](./04-DATA-MODEL.md)). 2. **Legacy-IDs bleiben nachvollziehbar** via `legacy_portal` + `legacy_id` auf jedem Datensatz + zentraler `legacy_import_map`. 3. **Kollisionen werden deterministisch aufgelöst** (siehe §3). 4. **Importe sind idempotent** – wiederholbare Skripte, bei erneutem Lauf passiert nichts doppeltes. 5. **Wiederholbar** (D-18): Das gesamte Import-Skript muss sowohl gegen die Test-Dumps als auch am Go-Live-Tag gegen den dann aktuellen Produktivstand laufen können. 6. **Dry-Run zuerst**, Produktiv-Run danach mit Snapshot-Backup vorher. --- ## 2. Konnektivität In `config/database.php` drei Connections: ```php 'connections' => [ 'mysql' => [ // Ziel-DB ], 'legacy_presseecho' => [ 'driver' => 'mysql', 'host' => env('LEGACY_PE_DB_HOST'), 'database' => env('LEGACY_PE_DB_NAME'), 'username' => env('LEGACY_PE_DB_USER'), 'password' => env('LEGACY_PE_DB_PASS'), 'charset' => 'utf8mb4', 'prefix' => '', 'strict' => false, ], 'legacy_businessportal' => [ // analog ], ], ``` ### Datenquellen-Modi (wichtig für Wiederholbarkeit) Das Import-Skript unterstützt drei Modi – alle liefern dieselben Ergebnisse: | Modus | Quelle | Einsatz | |---|---|---| | **`dump`** | Import der SQL-Dumps in lokale MySQL-Schemas `legacy_pe_snapshot` / `legacy_bp_snapshot`, dann darauf lesen | Lokal + CI | | **`direct`** | Read-Only-Verbindung zu den Produktiv-/Staging-Legacy-DBs | Test-Server + Go-Live-Tag | | **`fixture`** | Kleine Pest-Fixtures | Automatisierte Tests | Artisan-Flag: `--source=dump|direct|fixture`. --- ## 3. Kollisions-Handling Beide DBs haben z.B. `sfGuardUser.id = 1`. Regeln: ### 3.1 User-Konflikt via E-Mail Wenn in beiden Portalen die **gleiche E-Mail** existiert: | Fall | Handling | |---|---| | Gleiche E-Mail, gleicher User | → **Zusammenführung zu einem User**. Neuer User hat `portal = both`, behält beide `legacy_import_map`-Einträge. Daten (Companies, PMs) aus beiden Seiten hängen am selben User. | | Gleiche E-Mail, offensichtlich andere Person | → **Report + manuelle Entscheidung**. Standard: erst `presseecho` importieren; Konflikt im BP24-Import wird auf eine Konflikt-Liste gesetzt, die der Auftraggeber freigibt. | | Unterschiedliche E-Mails | → Zwei separate User, `portal = presseecho` bzw. `businessportal24`. | ### 3.2 Company-Konflikt via `name` - Wenn gleicher Name + zusammengeführter User → Merge (`portal = both`). - Sonst zwei separate Companies; Slug per Portal-Suffix dedupen (`acme-ag`, `acme-ag-bp24`). ### 3.3 Press-Release- und andere Entitäten - Unique-Constraints werden auf `(legacy_portal, legacy_id)` relaxed; neuer Slug ggf. mit Portal-Präfix bei Kollision. - Alle FKs werden via `legacy_import_map` neu aufgelöst. --- ## 4. Import-Ablauf (High-Level) > **Ist-Stand 2026-04-29:** Implementiert ist der Master-Command `legacy:import` mit `--source=presseecho|businessportal24|all` und `--step=categories|users|companies|contacts|press-releases|link-associations|all`. Die früher geplanten Commands `legacy:import-all` und `legacy:images-copy` existieren aktuell nicht als produktive Commands; Medienübernahme bleibt offen. ```bash # 0. Leere Ziel-DB (frische Migration) php artisan migrate:fresh --seed # 1. Dry-Run (Reports ohne Writes) php artisan legacy:import --source=all --dry-run php artisan legacy:archive-invoices --dry-run # 2. Reports prüfen (Konflikte, fehlende FKs, Counts) php artisan legacy:verify --no-report # 3. Echter Import php artisan legacy:import --source=presseecho --step=categories --force php artisan legacy:import --source=all --step=users --force php artisan legacy:import --source=presseecho --step=companies --force php artisan legacy:import --source=businessportal24 --step=companies --force php artisan legacy:import --source=presseecho --step=contacts --force php artisan legacy:import --source=businessportal24 --step=contacts --force php artisan legacy:import --source=presseecho --step=press-releases --force php artisan legacy:import --source=businessportal24 --step=press-releases --force php artisan legacy:import --step=link-associations --force php artisan legacy:archive-invoices php artisan legacy:fix-timestamps # 4. Verifikation php artisan legacy:verify # 5. Bilder-/Medien-Transfer # Noch offen: finaler Scope/Command # 6. Final-QA auf Staging ``` ### Master-Command `legacy:import` Ruft die Importer in der richtigen Reihenfolge auf, wenn `--step=all` genutzt wird. Jeder Importer ist selbst idempotent (via `legacy_import_map`). ``` legacy:import ├─ categories (+Translations DE/EN) ├─ users (+Profile + Rollen) ├─ companies (+company_user) ├─ contacts ├─ press-releases (+press_release_images + press_release_contact) └─ link-associations (contact_user aus Firmenzuordnungen) ``` Ergänzende Commands: ``` legacy:archive-invoices (→ legacy_invoices) legacy:fix-timestamps legacy:verify ``` Noch offen bzw. blockiert: - `legacy:grandfather-subscriptions` für aktive Alt-Abos (Kriterien fehlen) - Medien-/Bilddatei-Transfer Reihenfolge relevant wegen FKs; jede Subtabelle nutzt `legacy_import_map`, um bereits importierte Datensätze zu überspringen oder zu aktualisieren (Upsert). --- ## 5. Pro Entität: Transformationsregeln ### 5.1 Users | Legacy-Feld | Ziel | Transform | |---|---|---| | `sfGuardUser.username` | `users.name` | Display-Name; wenn `username` wie Mail aussieht + `email`-Feld leer → `users.email` | | `sfGuardUser.password` + `.salt` | – | **Wird nicht übernommen (D-09)**. `users.password = null`. | | `sfGuardUserProfile.email` | `users.email` | `lower()`, trim; UNIQUE-Check mit Konflikt-Handling §3.1 | | `is_active`, `is_super_admin`, `last_login`, `ip_address` | `users.*` | 1:1 | | `api_key` | – | **Nicht übernommen (D-05)**. User muss neuen Sanctum-Token erzeugen. | | Rolle | Spatie | `sfGuardGroup` → Mapping auf `admin` / `editor` / `customer` / `api-only` (konfigurierbare Mapping-Tabelle im Command) | Profilfelder in `profiles` anlegen (FK `user_id`). ### 5.2 Companies 1. Alle Companies aus beiden DBs lesen. 2. Gleichnamig + selber Owner → Merge (`portal = both`). 3. Sonst separate Company, Slug ggf. suffixen. 4. `CompanyUser` + `ResponsibleCompanyUser` in `company_user`-Pivot mit `role`. 5. Logo: alte Datei kopieren in `storage/app/public/companies/{id}/logo-original.{ext}`, Varianten (`sq`, `wide`, `dark`) werden beim ersten Admin-Aufruf erzeugt (lazy). ### 5.3 Press Releases - Owner-User via `legacy_import_map` auflösen. - `portal`-Spalte = Quell-Portal. - Slug: gleiche Regel wie Legacy, aber Unique auf `(portal, language, slug)`. - `text`: identisch übernehmen; Mojibake-Check (UTF-8 vs. Latin-1) einbauen. - `status`-String in neues Enum mappen (`'published' → published`, `''|null → draft`, `'rejected' → rejected`). - `uuid` neu generieren (für öffentliche Share-Links). - `published_at` aus `created_at` ableiten, falls `status=published`. ### 5.4 Images - DB-Record erst nach Datei-Transfer anlegen (Atomicität). - Pfad nach neuem Schema: `press-releases/{id}/{original-name}.ext`. - Alte Slug-Thumbnails werden NICHT kopiert – neu generiert on upload. - Redirect-Middleware sorgt für alte `/thumbnails/...`-URLs. ### 5.5 Rechnungen ⭐ ARCHIV (D-12) **Keine operative Migration in den neuen Rechnungskreis.** Alle Legacy-Rechnungen kommen vollständig in die read-only Archivtabelle `legacy_invoices`: - Command `legacy:archive-invoices` importiert alle Rechnungen beider Portale idempotent. - Jede Legacy-Zeile → ein `legacy_invoices`-Datensatz inkl. Status, Betrag, Datumsfeldern, Zahlart, User-Zuordnung, `raw_snapshot` und `pdf_payload` als JSON. - Für PDF-Abruf bleibt die Legacy-Logik erhalten: PDF wird aus den importierten DB-Daten bei Bedarf generiert. Es wird kein verpflichtender PDF-Dateibestand migriert; `pdf_path` ist höchstens Cache/Export. - Import-Report: Counts pro Portal, Statusverteilung, Summenvergleich, unzugeordnete `legacy_user_id`. - Kein Mahnwesen, keine Statuswechsel. Vor dem Go-Live-Rehearsal muss der Report gegen den aktuellen Produktiv-Snapshot geprüft werden. Unzugeordnete Rechnungen sind erlaubt, müssen aber fachlich bewertet werden. ### 5.6 Payment ⭐ NEU + GRANDFATHERING (D-13) > **Umgesetzt 2026-06-12** mit präzisierten Kriterien des Auftraggebers: > Quelle der Aktiv-Erkennung ist **ausschließlich das Rechnungsarchiv** > (`legacy_invoices`, D-12) — nicht die Legacy-Payment-Tabellen direkt. > Command: `legacy:grandfather-subscriptions` (idempotent, Replay-fähig). - **Legacy-PaymentOptions werden nicht übernommen.** Neue Produkte werden vom Auftraggeber definiert und als Stripe-Prices angelegt. Für die Grandfathered-Vereinbarungen entstehen versteckte Katalog-Platzhalter (`payment_options.article_number = LEGACY-{PE|BP}-{Artikel}`, `is_hidden = true`); die verbindlichen Beträge liegen pro Vereinbarung in `legacy_conditions`. - **Aktiv-Regel** (aus dem Archiv abgeleitet): jüngste Rechnung pro (Portal, Legacy-`user_payment_option`) mit `pdf_payload.payment_option.type = 'recurring'` und `pdf_payload.user_payment_option.status = 'active'`; `next_due_date` darf höchstens `--grace-months` (Default 12) überfällig sein, sonst gilt die Vereinbarung als stale und bleibt reines Archiv. Einmal-Käufe (`type = single`) werden nie übernommen. - **Übernahme** als `grandfathered` in `user_payment_options`: - `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until_date` (nullable), `stripe_subscription_id = null`. - `current_period_start = service_period_begin_date` der jüngsten Rechnung, `current_period_end = next_due_date` → der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) stellt die nächste Rechnung zum gewohnten (jährlichen) Rhythmus aus. - **Beträge (Klarstellung 12.06.):** Legacy fakturierte **brutto** (Steuer inkludiert); steuerbefreite Kunden erhielten den Netto-Ausweis (`is_netto`). Die Migration leitet daraus die **Netto-Vertragsbasis** ab (`legacy_conditions.net_cents`; brutto ÷ 1,19 bzw. Netto-Betrag direkt). Die Steuer bestimmt der `VatResolver` pro Rechnung neu: DE immer mit Steuer, EU nur mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit — für deutsche Bestandskunden bleibt der Bruttobetrag unverändert, die Steuer wird künftig sauber ausgewiesen (`invoices.tax_note` bei Befreiung). - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). Neue manuelle Rechnungen entstehen im **MAN-Rechnungskreis** (`invoices`), nie im Archiv. - Scheduler `ExpireGrandfatheredSubscriptions` (Customer-Benachrichtigung am `grandfathered_until`) bleibt offen — folgt mit dem Stripe-Billing-Block. - Alle historischen `user_payments` werden als Information ins Archiv geschrieben (analog `legacy_invoices` – optional). - **Replay (D-18)**: Re-Runs aktualisieren bestehende Einträge anhand `legacy_conditions.legacy_portal` + `legacy_user_payment_option_id` — der Lauf kurz vor dem Relaunch übernimmt damit den dann aktuellen Stand ohne Duplikate. ### 5.7 Coupons Vertagt (D-16) – werden in Phase 1 nicht migriert/gebaut. ### 5.8 Newsletter - Nur `newsletter_subscriptions` übernehmen (E-Mails + Doppel-Opt-In-Status). - Neue Workflows/Kampagnen-Komponenten werden separat gebaut. ### 5.9 Promotion Links / FooterCode - Promotion Links **ignorieren** (D-14). - FooterCode **übernehmen** (inkl. `category_footer_code`-Pivot). --- ## 6. Verifikations-Checks (`legacy:verify`) Command prüft: 1. **Counts**: pro Entität `legacy_source_count` vs. `target_count` + Delta. 2. **Foreign Keys**: keine dangling FKs (z. B. `press_releases.user_id` existiert in `users`). 3. **User-Merges**: Alle `portal = both`-User auflisten. 4. **E-Mail-Duplikate** (Unique-Verletzungen hätten Imports blockiert – Check trotzdem). 5. **Invoice-Summen**: Σ `amount_cents` pro Portal in `legacy_invoices` vs. Summe aus Legacy-DB. 6. **Press Release Counts per Kategorie pro Portal**. 7. **Grandfathered Subscriptions**: Liste aller `user_payment_options.status = grandfathered` mit `grandfathered_until`. Ausgabe als Tabelle und `storage/app/migration/verify-{timestamp}.json`. --- ## 7. Wiederholbarkeit / Produktiv-Replay ⭐ D-18 **Ziel:** Am Go-Live-Tag wird derselbe Code gegen den dann aktuellen Produktivstand laufen – deshalb: - **Idempotenz via `legacy_import_map`:** Wiederholte Läufe aktualisieren existierende Datensätze (Upsert-Strategie) oder überspringen sie (je nach Entität). Kein Duplikat-Import. - **Delta-Modus:** aktuell noch nicht implementiert; bei Bedarf vor Go-Live ergänzen oder über frischen Vollimport/Rehearsal ersetzen. - **Rehearsal-Pflicht:** Vor dem Go-Live wird der komplette Importlauf mindestens einmal auf einem frischen Staging mit einem **aktuellen Produktiv-Snapshot** getestet (< 24 h alt). - **Reset-Option:** aktuell nicht als produktiver Command dokumentiert; Staging-Rehearsals sollten auf frischer Ziel-DB laufen. ### 7.1 Go-Live-Runbook (Kurz) ``` 1. Maintenance-Mode aktivieren (alte Systeme read-only) 2. Frischer Produktiv-Dump beider Legacy-DBs → Staging importieren (Rehearsal) 3. Nach erfolgreichem Rehearsal: frischer Zielsystem-Dump als Restore-Point 4. Migration auf Produktiv-Zielsystem: `php artisan migrate:fresh --force` 5. Importfolge aus `MIGRATION-STEPS.md` ausführen (`legacy:import`, `legacy:archive-invoices`, `legacy:fix-timestamps`) 6. Medien-/Bilddateien übertragen, sobald finaler Scope/Command feststeht 7. `php artisan legacy:verify` 8. Go-Live-Mailing rausschicken (Passwort-Reset an alle aktiven User) 9. DNS-Cutover 10. Alte Systeme in Kalt-Archiv verschieben ``` --- ## 8. Rollback Produktiv-Import erfolgt ausschließlich auf einer **frischen Ziel-DB**, die vor dem Lauf vollständig gedumpt wird (`mysqldump`). Bei Problemen: Dump zurückspielen. Zusätzlich bietet jeder `legacy:import-*`-Command eine `--reset`-Option (nur in Staging). --- ## 9. Zeitplan | Schritt | Dauer | |---|---| | Legacy-Models + Connections + Dumps lokal importieren | 0.5 d | | Users + Companies + Contacts Import + Konflikt-Logik | 1.0 d | | PressReleases + Images + Categories Import | 1.0 d | | Newsletter + Blacklist + FooterCode | 0.25 d | | Legacy-Invoices-Archiv + PDF-Transfer | 0.5 d | | Grandfather-Subscriptions-Mapping | 0.25 d | | Verifikation + Reporting | 0.5 d | | Rehearsal-Lauf gegen produktiven Snapshot | 0.5 d | | **Σ** | **~4.5 d** | --- ## 10. Pre-Flight-Checkliste (vor Produktiv-Import) - [ ] Beide Legacy-DB-Dumps oder direkte Read-Connections aktuell (<24 h) - [ ] Staging-Import abgeschlossen + verifiziert - [ ] Rehearsal-Lauf erfolgreich - [ ] Auftraggeber hat Konflikt-Fälle (Mails, Firmen) freigegeben - [ ] Grandfathered-Liste vom Auftraggeber abgenommen - [ ] Backup der Ziel-DB gemacht - [ ] Alte Systeme auf read-only (Maintenance-Page) - [ ] Monitoring läuft (Log-Channel, Slack/Mail-Alerts) - [ ] Go-Live-Mailing vorbereitet + Versandzeitpunkt festgelegt