presseportale/dev/migration 2026/05-DATABASE-MERGE.md
Kevin Adametz 1cd4d8e33a P6.6: legacy:grandfather-subscriptions — aktive Legacy-Abos aus dem Rechnungsarchiv migrieren
Kriterien vom Auftraggeber (12.06.2026): Quelle der Aktiv-Erkennung ist
ausschliesslich das read-only Rechnungsarchiv legacy_invoices (D-12).
Legacy-Rechnungen bleiben Archiv; neue manuelle Rechnungen entstehen im
MAN-Rechnungskreis.

- Aktiv-Regel: juengste Rechnung pro (Portal, Legacy-Vereinbarung) mit
  payment_option.type=recurring und user_payment_option.status=active;
  next_due_date max. --grace-months (Default 12) ueberfaellig, sonst
  stale -> bleibt reines Archiv. Einmal-Kaeufe werden nie uebernommen.
- Uebernahme als grandfathered in user_payment_options:
  current_period_end = next_due_date, Betraege/Intervall der letzten
  Legacy-Rechnung in legacy_conditions -> der taegliche MAN-Lauf
  (billing:generate-manual-invoices) fakturiert zum gewohnten
  jaehrlichen Rhythmus weiter. Versteckte Katalog-Platzhalter
  LEGACY-{PE|BP}-{Artikel} in payment_options.
- Replay-faehig (D-18): Re-Runs aktualisieren anhand der Legacy-IDs in
  legacy_conditions statt zu duplizieren — die Kern-Migration laeuft
  kurz vor dem Relaunch erneut.
- Optionen: --dry-run, --as-of, --grace-months, --no-report; JSON-Report
  nach storage/app/migration/. Dry-Run gegen Test-Snapshot: 22 aktive
  jaehrliche Vereinbarungen, davon 4 sofort faellig, 0 stale.
- Doku: MIGRATION-STEPS.md (Runbook-Reihenfolge nach archive-invoices),
  05-DATABASE-MERGE §5.6, 12-NAECHSTE-SCHRITTE 6.6, 08-PROGRESS,
  PHASE-9-Plan + Checkliste.

Tests: GrandfatherLegacySubscriptionsTest (7, inkl. End-to-End
Migration -> MAN-Rechnung mit Legacy-Betraegen). Suite: 475 passed,
4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:35:48 +00:00

324 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, mit den Beträgen der letzten Legacy-Rechnung (`legacy_conditions.amount/tax/total_cents`).
- **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