presseportale/dev/migration 2026/05-DATABASE-MERGE.md
Kevin Adametz 894a9436b0 USt-Behandlung: Netto-Preise, VatResolver und Steuer-Ausweis im MAN-Kreis
Einwand/Entscheidung 12.06.2026: Legacy fakturierte brutto (Steuer
inkludiert, z. B. 199 Euro; steuerbefreite Kunden mit Netto-Ausweis
167,23). Alle neuen Preise sind netto; die Steuer wird zur
Rechnungsstellung sauber validiert und ausgewiesen.

- VatResolver + VatTreatment: DE grundsaetzlich immer mit Steuer, EU nur
  mit (formal plausibler) USt-ID befreit (Reverse Charge inkl.
  Pflichthinweis), Drittlaender grundsaetzlich befreit;
  EU-Laenderliste + vat_rate in config/billing.php
- Schema: billing_addresses.vat_id + invoice_billing_addresses.vat_id
  (Snapshot pro Rechnung), invoices.tax_note; Profil-Formular schreibt
  die vorhandene USt-ID jetzt auch an die Rechnungsadresse
- ManualInvoiceService: rechnet auf Netto-Vertragsbasis
  (legacy_conditions.net_cents bzw. Netto-Katalogpreis) und bestimmt
  Steuer/is_netto/tax_note pro Rechnung ueber den VatResolver
- legacy:grandfather-subscriptions: leitet net_cents aus der letzten
  Legacy-Rechnung ab (brutto / 1,19 bzw. is_netto-Betrag direkt);
  fuer DE-Bestandskunden bleibt der Bruttobetrag unveraendert
  (199 brutto -> 167,23 netto + 31,77 USt = 199,00)
- Doku: Decision-Update 2.1 (Netto-Klarstellung), Phase-9-Plan,
  Checkliste, 05-DATABASE-MERGE 5.6; offen: VIES-Validierung der USt-ID

Tests: VatResolverTest (Datasets fuer alle Faelle), Reverse-Charge/
EU-/Drittland-Rechnungen, Netto-Ableitung; Suite 490 passed, 4 skipped.
Pint clean.

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

325 lines
16 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.
- **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