# 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. ```sql 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_id` → `contact_id`. ```sql -- Ä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_id` → `contact_id`, `lead_id` → `inquiry_id`. ```sql -- 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`) ```sql 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`) ```sql 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`) ```sql 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`) ```sql 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 ```php // 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 ```sql ALTER TABLE customer ADD COLUMN merged_into_id INT NULL; ALTER TABLE customer ADD COLUMN merged_at DATETIME NULL; ``` #### 1c) Migration ausführen ```php // 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 ```php // 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. ```sql -- 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. ```sql 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. ```sql -- 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? | **Ja** — `inquiry_id` in `bookings` bleibt `NULL`able; 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:** ```php // In contacts:merge-duplicates: // Master = Customer::where('email', $email)->orderByDesc('updated_at')->first() // Kein manuelles Eingreifen erforderlich ``` **Zu 2 — Direktbuchung:** ```sql -- 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:** ```php // 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. ```