mein-sterntours/dev/customer-bookings/konzept.md
Phase-1-Rollback-Agent e3dc1afd8e WIP: Sicherheitsnetz vor Phase-1-R\u00fcckbau
Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands,
Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries')
+ Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php).

Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/
verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist
und direkt auf Live deploybar wird.

Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz

Made-with: Cursor
2026-04-17 13:40:31 +00:00

15 KiB

Konzept: Neustrukturierung Customer / Lead / Booking

Status: Entwurf
Erstellt: April 2025
Ziel: Beseitigung der Daten-Redundanz und Vereinfachung der Kernstruktur


1. Ist-Zustand — Analyse der Probleme

1.1 Aktuelle Datenfluss-Kette

Anfrage (Lead)
  └── customer_id  →  Customer (neu angelegt pro Lead!)
  └── LeadMail
  └── LeadFile
  └── LeadNotice
  └── LeadParticipant
      |
      | createBooking()
      ↓
Buchung (Booking)
  └── customer_id  →  Customer (derselbe, aber Daten werden separat gepflegt)
  └── lead_id      →  Lead (Rückreferenz)
  └── CustomerMail
  └── BookingFile
  └── BookingNotice
  └── Participant  (Kopie von LeadParticipant!)

1.2 Konkrete Probleme

Problem Auswirkung
Kunde wird pro Anfrage neu angelegt Bucht ein Kunde zweimal: 2 Customer-Datensätze, keine Kundenhistorie
Teilnehmer doppelt gespeichert participant_name/firstname/birthdate in lead, booking UND lead_participant + participant
Mail-Tabellen aufgesplittet lead_mail für Anfragen, customer_mail für Buchungen — gleiche Struktur, getrennte Tabellen
Notizen aufgesplittet lead_notice und booking_notice — identisch, getrennt
Datei-Tabellen aufgesplittet lead_file, booking_file, customer_fewo_file — gleiche Funktion
Kein übergreifendes Kunden-Profil Alle Buchungen/Anfragen eines Kunden nur per ID-Lookup findbar, aber nie wirklich verknüpft
createBooking() kopiert Daten LeadRepository::createBooking() kopiert Participants 1:1 — spätere Änderungen laufen auseinander

2. Soll-Zustand — Zielbild

2.1 Kernprinzip

Contact (Stammkunde — einmal, für immer)
  └── hasMany: Inquiry (früher: Lead)
  └── hasMany: Booking

Inquiry (Anfrage)
  └── belongsTo: Contact
  └── hasMany: Booking (eine Anfrage kann zu einer Buchung werden)
  └── hasMany: Communication (Mails, Notizen, Dateien)

Booking (Buchung)
  └── belongsTo: Contact
  └── belongsTo: Inquiry
  └── hasMany: Communication
  └── hasMany: Participant

2.2 Neue Tabellen-Struktur

contacts (ersetzt customer)

Identisch zur customer-Tabelle — nur umbenannt und mit Deduplizierungslogik ausgestattet.

CREATE TABLE contacts (
    -- alle bisherigen Felder aus customer
    id, salutation_id, title, name, firstname, birthdate,
    company, street, zip, city, email, phone, phonebusiness,
    phonemobile, fax, bank, bank_code, bank_account_number,
    credit_card_type_id, credit_card_number,
    credit_card_expiration_date, participants_remarks,
    miscellaneous_remarks, country_id,
    -- neu:
    merged_into_id INT NULL,  -- zeigt auf Haupt-Datensatz bei Duplikaten
    created_at, updated_at, deleted_at
);

inquiries (umbenennung von lead)

Weitgehend identisch, customer_idcontact_id.

-- Änderungen gegenüber lead:
ALTER TABLE inquiries
    RENAME COLUMN customer_id TO contact_id;
-- Teilnehmerfelder bleiben vorerst (Abwärtskompatibilität Phase 1)
-- Entfernung in Phase 3

bookings (geringfügige Anpassung)

customer_idcontact_id, lead_idinquiry_id.

-- inquiry_id bleibt nullable:
-- Direktbuchungen (ohne vorherige Anfrage) sollen möglich sein → inquiry_id = NULL
-- Teilnehmerfelder (participant_name etc.) fallen in Phase 3 weg
-- → wandern komplett in die participants-Tabelle

participants (konsolidiert lead_participant + participant)

CREATE TABLE participants (
    id,
    contact_id       INT NULL,    -- direkte Zuordnung zum Stammkunden (optional)
    inquiry_id       INT NULL,    -- FK auf inquiries (früher lead_participant)
    booking_id       INT NULL,    -- FK auf bookings  (früher participant)
    salutation_id    INT NULL,
    name             VARCHAR,
    firstname        VARCHAR,
    birthdate        DATE NULL,
    participant_child BOOL DEFAULT FALSE,
    nationality_id   INT NULL,
    is_lead_contact  BOOL DEFAULT FALSE,  -- markiert den Hauptreisenden
    created_at, updated_at
);

communications (konsolidiert lead_mail + customer_mail)

CREATE TABLE communications (
    id,
    contact_id   INT NULL,
    inquiry_id   INT NULL,
    booking_id   INT NULL,
    -- alle bisherigen Felder aus lead_mail/customer_mail:
    from_email, to_email, from_name, to_name,
    subject, body, sent_at,
    dir, subdir,
    -- Typ-Unterscheidung:
    context      ENUM('inquiry', 'booking') NOT NULL,
    created_at, updated_at
);

notices (konsolidiert lead_notice + booking_notice)

CREATE TABLE notices (
    id,
    inquiry_id    INT NULL,
    booking_id    INT NULL,
    from_user_id  INT NOT NULL,
    to_user_id    INT NULL,
    message       TEXT,
    edit_at       DATETIME NULL,
    created_at, updated_at
);

attachments (konsolidiert lead_file + booking_file + customer_fewo_file)

CREATE TABLE attachments (
    id,
    contact_id   INT NULL,
    inquiry_id   INT NULL,
    booking_id   INT NULL,
    disk, path, filename, mime_type, filesize,
    identifier   VARCHAR NULL,
    created_at, updated_at
);

3. Migrationsstrategie — Phasen

Die Migration ist bewusst rückwärtskompatibel geplant: jede Phase kann unabhängig deployed werden, das System bleibt zu jeder Zeit funktionsfähig.


Phase 1 — Contact-Deduplizierung (Kern-Problem lösen)

Ziel: Mehrfach-Kunden zusammenführen. Kein Datenverlust.

1a) Duplikate identifizieren

// artisan-Befehl: php artisan contacts:find-duplicates
// Gruppierungsstrategie (absteigend nach Konfidenz):
// 1. Gleiche E-Mail-Adresse → sicher identisch
// 2. Gleicher Name + Vorname + Geburtsdatum → sehr wahrscheinlich identisch
// 3. Gleicher Name + Vorname + PLZ → wahrscheinlich identisch (manuell prüfen)

Ausgabe: CSV-Liste mit Gruppen und vorgeschlagenem Haupt-Datensatz.

1b) merged_into_id Spalte hinzufügen

ALTER TABLE customer ADD COLUMN merged_into_id INT NULL;
ALTER TABLE customer ADD COLUMN merged_at DATETIME NULL;

1c) Migration ausführen

// artisan-Befehl: php artisan contacts:merge-duplicates --dry-run
// Dann: php artisan contacts:merge-duplicates --confirm
//
// Für jeden gefundenen Duplikat-Cluster:
// 1. Neuesten Datensatz (höchste ID / neuestes updated_at) als Master wählen
//    → Entscheidung: immer die neueste Adresse gewinnt
// 2. Alle leads: customer_id → master_id
// 3. Alle bookings: customer_id → master_id
// 4. Duplikat: merged_into_id = master_id setzen

1d) Customer-Model: Abfragen abfangen

// app/Models/Customer.php — GlobalScope hinzufügen
protected static function booted(): void
{
    static::addGlobalScope('not_merged', function ($query) {
        $query->whereNull('merged_into_id');
    });
}

Aufwand: ~2 Tage
Risiko: Gering — nur Lese-/Schreib-Operationen, keine Struktur-Änderung


Phase 2 — Tabellen umbenennen (Customer → Contact, Lead → Inquiry)

Ziel: Sprechende Namen einführen, interne Logik bereinigen.

-- Migration:
RENAME TABLE customer  TO contacts;
RENAME TABLE lead      TO inquiries;

-- Booking:
ALTER TABLE bookings RENAME COLUMN lead_id TO inquiry_id;
-- customer_id bleibt vorerst, wird in Phase 2b auf contact_id umgestellt

Code-Änderungen:

  • App\Models\Customer → bleibt, aber protected $table = 'contacts'
  • App\Models\Lead → bleibt, aber protected $table = 'inquiries'
  • Alle Referenzen auf lead_id in Booking → inquiry_id
  • Controller-Routen: /lead//inquiry/ (alte URLs: Redirect 301)

Aufwand: ~3 Tage
Risiko: Mittel — viele Stellen im Code müssen angepasst werden. Sorgfältige Suche mit grep -r "lead_id\|customer_id\|'lead'\|'customer'".


Phase 3 — Participants konsolidieren

Ziel: lead_participant und participant zu einer Tabelle zusammenführen. Teilnehmerfelder in lead und booking entfernen.

CREATE TABLE participants (
    -- wie in 2.2 beschrieben
);

-- Daten migrieren:
INSERT INTO participants (inquiry_id, name, firstname, birthdate, ...)
    SELECT lead_id, participant_name, participant_firstname, participant_birthdate, ...
    FROM lead_participant;

INSERT INTO participants (booking_id, name, firstname, birthdate, ...)
    SELECT booking_id, participant_name, participant_firstname, participant_birthdate, ...
    FROM participant;

-- Hauptreisenden aus den denormalisierten Feldern migrieren:
-- (lead.participant_name → participant mit is_lead_contact = true)
INSERT INTO participants (inquiry_id, name, firstname, birthdate, is_lead_contact, ...)
    SELECT id, participant_name, participant_firstname, participant_birthdate, true, ...
    FROM lead
    WHERE participant_name IS NOT NULL;

Code-Änderungen:

  • LeadRepository::createBooking() — kein Kopieren von Participants mehr nötig; bestehende inquiry_id-Participants werden automatisch per booking_id ergänzt
  • lead_participant, participant Tabellen nach Abgleich droppen
  • Denormalisierte Felder (participant_name etc.) in lead und booking mit NULL befüllen, dann in Phase 4 entfernen

Aufwand: ~4 Tage
Risiko: Mittel — betrifft PDF-Generierung, Buchungsbestätigungen; diese müssen angepasst werden


Phase 4 — Communications / Notices / Attachments konsolidieren

Ziel: Die 6+ Mail/Notiz/Datei-Tabellen auf 3 gemeinsame Tabellen reduzieren.

-- Mails migrieren:
INSERT INTO communications (inquiry_id, context, from_email, ...)
    SELECT lead_id, 'inquiry', from_email, ... FROM lead_mail;

INSERT INTO communications (booking_id, context, from_email, ...)
    SELECT booking_id, 'booking', from_email, ... FROM customer_mail;

-- Notizen migrieren:
INSERT INTO notices (inquiry_id, from_user_id, message, ...)
    SELECT lead_id, from_user_id, message, ... FROM lead_notice;

INSERT INTO notices (booking_id, from_user_id, message, ...)
    SELECT booking_id, from_user_id, message, ... FROM booking_notice;

-- Dateien migrieren:
INSERT INTO attachments (inquiry_id, disk, path, ...)
    SELECT lead_id, disk, path, ... FROM lead_file;

INSERT INTO attachments (booking_id, disk, path, ...)
    SELECT booking_id, disk, path, ... FROM booking_file;

Code-Änderungen:

  • LeadMailRepository, CustomerMailRepository → gemeinsames CommunicationRepository
  • LeadFileRepository, BookingFileRepository → gemeinsames AttachmentRepository
  • MailDirService (bereits vorhanden) erhält einheitlichen Datenzugriff
  • Views für Mails/Notizen/Dateien in Lead und Booking können shared werden

Aufwand: ~5 Tage
Risiko: Hoch — betrifft viele Views und Controller. Muss sehr sorgfältig getestet werden.


4. Neues Beziehungsmodell (Zielbild)

Contact (1)
 ├── (N) Inquiry
 │    ├── (N) Participant   [inquiry_id]
 │    ├── (N) Communication [inquiry_id]
 │    ├── (N) Notice        [inquiry_id]
 │    ├── (N) Attachment    [inquiry_id]
 │    └── (1) Booking
 │          ├── (N) Participant   [booking_id]
 │          ├── (N) Communication [booking_id]
 │          ├── (N) Notice        [booking_id]
 │          ├── (N) Attachment    [booking_id]
 │          └── (N) BookingDocument, BookingInvoice, ...
 └── (N) Booking  (direkt, ohne Umweg über Inquiry)

5. Controller-Vereinfachung

Nach den Migrationen können Controller und Views schrittweise zusammengelegt werden:

Jetzt Ziel
LeadController InquiryController
CustomerController ContactController
BookingController bleibt, aber schlanker
LeadMailController + CustomerMailController CommunicationController
CustomerFewoMailController CommunicationController (mit Fewo-Kontext)

Die Detail-Seiten für Lead und Booking sind strukturell fast identisch. Langfristig könnte es eine gemeinsame Basis-View geben, die je nach Kontext unterschiedliche Sektionen einblendet.


6. Empfohlene Reihenfolge

Phase 1 (Contact-Deduplizierung)
    → Unmittelbarer Gewinn: Kundenhistorie ist korrekt
    → Unabhängig von allen anderen Phasen
    → Sofort umsetzbar

Phase 2 (Umbenennung)
    → Voraussetzung für Phase 3 & 4
    → Kann parallel zur normalen Entwicklung erfolgen

Phase 3 (Participants)
    → Abhängig von Phase 2
    → Direkte Auswirkung auf PDF-Generierung — sorgfältig testen

Phase 4 (Communications/Notices/Attachments)
    → Größtes Vorhaben, letzter Schritt
    → Kann in Teilschritte aufgesplittet werden
      (erst Notices, dann Attachments, dann Communications)

7. Was NICHT geändert werden soll

  • Buchungs-Detailseite (booking.detail): Bleibt strukturell erhalten — zu komplex für gleichzeitige Überarbeitung
  • PDF-Generierung: Erst nach Phase 3 anpassen (Participants-Konsolidierung)
  • Fewo-Buchungsstruktur (FewoLodging, TravelUserBookingFewo): Separates Thema, nicht Teil dieses Konzepts
  • Legacy-Datenbank (mysql_stern): Keine Änderungen — bleibt read-only

8. Entscheidungen (geklärt)

# Frage Entscheidung
1 Welcher Datensatz wird Master bei Duplikaten mit verschiedenen Adressen? Immer der neueste (höchste ID / jüngstes updated_at) — vollautomatisch, kein manueller Review
2 Direktbuchung ohne vorherige Anfrage? Jainquiry_id in bookings bleibt NULLable; Direktbuchungen sind vorgesehen
3 Was bedeutet is_rebook? Umbuchung (nicht Wiederbuchung). Checkbox „Umbuchung abgeschlossen" auf der Anfrage. Feld bleibt auf inquiries, keine Sonderbehandlung nötig
4 DSGVO — Einwilligungen pro Datensatz? Kein Problem. Einwilligung war immer Pflichtfeld beim Formular-Submit. Jede Anfrage enthält implizit eine gültige Einwilligung — keine separate Migrationsprüfung nötig

Auswirkungen auf die Migration

Zu 1 — Automatische Master-Wahl:

// In contacts:merge-duplicates:
// Master = Customer::where('email', $email)->orderByDesc('updated_at')->first()
// Kein manuelles Eingreifen erforderlich

Zu 2 — Direktbuchung:

-- bookings.inquiry_id bleibt NULL erlaubt (bereits so geplant)
-- Neuer Einstiegspunkt im UI: "Buchung direkt anlegen" ohne Lead-Voraussetzung
-- BookingController::store() darf inquiry_id weglassen

Zu 3 — is_rebook bleibt auf Inquiry:

// Bedeutung: diese Anfrage ist eine Umbuchung einer bestehenden Buchung
// is_rebook: bool — Checkbox "Umbuchung abgeschlossen"
// Keine Änderung an der Logik, nur Umbenennung mit Phase 2

Zu 4 — DSGVO:

Keine zusätzlichen Schritte nötig.
Beim Mergen zweier Kontakte gilt: beide haben gültige Einwilligungen erteilt.
Der zusammengeführte Master-Datensatz ist datenschutzrechtlich unbedenklich.