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>
325 lines
16 KiB
Markdown
325 lines
16 KiB
Markdown
# 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
|