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

16 KiB
Raw Blame History

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).
  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:

'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.

# 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.
  • 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