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
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_id → contact_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_id → contact_id, lead_id → inquiry_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, aberprotected $table = 'contacts'App\Models\Lead→ bleibt, aberprotected $table = 'inquiries'- Alle Referenzen auf
lead_idin 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; bestehendeinquiry_id-Participants werden automatisch perbooking_idergänztlead_participant,participantTabellen nach Abgleich droppen- Denormalisierte Felder (
participant_nameetc.) inleadundbookingmitNULLbefü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→ gemeinsamesCommunicationRepositoryLeadFileRepository,BookingFileRepository→ gemeinsamesAttachmentRepositoryMailDirService(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 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.