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
This commit is contained in:
parent
389d5d1820
commit
e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions
242
dev/audit-april-2025.md
Normal file
242
dev/audit-april-2025.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Code-Audit – mein.sterntours.de (April 2025)
|
||||
|
||||
Durchgeführt nach Framework-Update auf Laravel 10 / PHPUnit 10.
|
||||
|
||||
---
|
||||
|
||||
## Status-Übersicht
|
||||
|
||||
| Bereich | Status |
|
||||
|---------|--------|
|
||||
| PHPUnit-Konfiguration | ✅ behoben |
|
||||
| UserFactory | ✅ auf Laravel 10 migriert |
|
||||
| Test-Abdeckung | ✅ Grundgerüst erstellt |
|
||||
| Composer-Autoload | ✅ behoben |
|
||||
| User/Customer/Lead/Booking HasFactory | ✅ hinzugefügt |
|
||||
| Hardcoded API-Key | ✅ behoben (→ `config('app.success_key')`) |
|
||||
| Code-Duplizierung Services | ✅ `MailDirService` extrahiert |
|
||||
| BookingController Request-Facade | ✅ auf Injection umgestellt |
|
||||
| Util.php Carbon-Calls | ✅ behoben |
|
||||
| Leere Konstruktoren | ✅ entfernt |
|
||||
| Factories Customer/Lead/Booking | ✅ erstellt |
|
||||
| Veraltetes Frontend-Tooling | ℹ️ bekannt – separates Vorhaben |
|
||||
|
||||
---
|
||||
|
||||
## Behobene Probleme
|
||||
|
||||
### 1. `phpunit.xml` — PHPUnit 10 Inkompatibilität
|
||||
|
||||
**Datei:** `phpunit.xml`
|
||||
|
||||
PHPUnit 10 hat mehrere Attribute aus dem XML entfernt, die vorher vorhanden waren. Das führte zu Warnings bzw. Fehlern beim Testlauf.
|
||||
|
||||
**Entfernt:**
|
||||
- `backupStaticAttributes` (seit PHPUnit 9.6 deprecated, in 10 entfernt)
|
||||
- `convertErrorsToExceptions`, `convertNoticesToExceptions`, `convertWarningsToExceptions` (in PHPUnit 10 entfernt)
|
||||
- `processIsolation`, `stopOnFailure` (in PHPUnit 10 aus XML entfernt → nur noch als CLI-Flag)
|
||||
|
||||
**Geändert:**
|
||||
- `<filter><whitelist>` → `<coverage><include>` (PHPUnit 10 Syntax)
|
||||
|
||||
---
|
||||
|
||||
### 2. `database/factories/UserFactory.php` — Laravel 7 Syntax
|
||||
|
||||
**Datei:** `database/factories/UserFactory.php`
|
||||
|
||||
Die alte Factory nutzte die Laravel 7 Closure-Syntax (`$factory->define()`), die in Laravel 10 nicht mehr unterstützt wird.
|
||||
|
||||
**Vorher:**
|
||||
```php
|
||||
$factory->define(App\User::class, function (Faker $faker) { ... });
|
||||
```
|
||||
|
||||
**Nachher:** Vollständige Laravel 10 `Factory`-Klasse mit States (`admin()`, `inactive()`).
|
||||
|
||||
---
|
||||
|
||||
### 3. `composer.json` — Veraltetes Classmap-Autoloading
|
||||
|
||||
**Datei:** `composer.json`
|
||||
|
||||
```json
|
||||
// Vorher (Laravel 7 Stil):
|
||||
"classmap": ["database/seeds", "database/factories"]
|
||||
|
||||
// Nachher (PSR-4):
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeds/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `app/User.php` — HasFactory-Trait fehlte + ungültiger Import
|
||||
|
||||
`HasFactory` wurde hinzugefügt, damit `User::factory()` in Tests funktioniert.
|
||||
|
||||
Der ungültige Import `PhpOffice\PhpSpreadsheet\Calculation\Statistical\Distributions\F` (nie benutzt) wurde entfernt.
|
||||
|
||||
---
|
||||
|
||||
## Offene Probleme (Handlungsbedarf)
|
||||
|
||||
### ⚠️ Hardcoded API-Key (Priorität: HOCH)
|
||||
|
||||
**Datei:** `app/Http/Controllers/API/BookingController.php:12`
|
||||
|
||||
```php
|
||||
private $successKey = 'f6077389c9ce710e554763a5de02c8ec';
|
||||
```
|
||||
|
||||
Der API-Key ist direkt im Source Code und damit im Git-Repository. Das ist ein Sicherheitsrisiko.
|
||||
|
||||
**Lösung:**
|
||||
```php
|
||||
// .env
|
||||
BOOKING_IMPORT_KEY=f6077389c9ce710e554763a5de02c8ec
|
||||
|
||||
// Controller
|
||||
private string $successKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->successKey = config('app.booking_import_key');
|
||||
}
|
||||
```
|
||||
|
||||
Dazu in `config/app.php` ergänzen:
|
||||
```php
|
||||
'booking_import_key' => env('BOOKING_IMPORT_KEY'),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Code-Duplizierung: `Services/Booking.php` und `Services/Lead.php`
|
||||
|
||||
Beide Services enthalten nahezu identische Methoden:
|
||||
- `getCustomerMailDirs()`
|
||||
- `getCustomerMailDir($id)`
|
||||
- `getCustomerMailName($dir, $id)`
|
||||
- `getCustomerMailEmails($dir, $id)`
|
||||
- `setOutputDirs($dir, $subdir)`
|
||||
- `getMailDirNotInOutput($id, $dir)`
|
||||
|
||||
**Lösung:** Einen gemeinsamen `MailDirService` (oder Trait) extrahieren.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ `BookingController` nutzt Request-Facade statt Dependency Injection
|
||||
|
||||
**Datei:** `app/Http/Controllers/BookingController.php:5`
|
||||
|
||||
```php
|
||||
use Request; // Facade
|
||||
// ...
|
||||
$data = Request::all();
|
||||
```
|
||||
|
||||
**Lösung:** `Illuminate\Http\Request $request` als Parameter injizieren.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ `Services/Util.php` — veraltete Carbon-Nutzung
|
||||
|
||||
**Datei:** `app/Services/Util.php:54-58`
|
||||
|
||||
```php
|
||||
\Carbon::parse($date) // veraltet
|
||||
\Util::formatDateDB() // statischer Aufruf über Facade-Alias
|
||||
```
|
||||
|
||||
**Lösung:** `Carbon\Carbon::parse($date)` und `self::formatDateDB()` verwenden.
|
||||
|
||||
---
|
||||
|
||||
### ℹ️ `API/BookingController` — leerer Konstruktor
|
||||
|
||||
**Datei:** `app/Http/Controllers/API/BookingController.php:17-19`
|
||||
|
||||
```php
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
Leere Konstruktoren ohne Parameter sollen laut Projekt-Konvention entfernt werden.
|
||||
|
||||
---
|
||||
|
||||
### ℹ️ Frontend-Tooling — stark veraltet
|
||||
|
||||
**Datei:** `package.json`
|
||||
|
||||
| Package | Aktuell | Stand |
|
||||
|---------|---------|-------|
|
||||
| `laravel-mix` | 2.1.11 | 2018 — aktuell: 6.x |
|
||||
| `cross-env` | 5.1.4 | veraltet |
|
||||
| `node-sass` | ~4.7.2 | deprecated → `sass` (dart-sass) verwenden |
|
||||
| `bootstrap` | ~4.1.1 | Bootstrap 5.x verfügbar |
|
||||
|
||||
Ein Frontend-Update ist ein größeres Vorhaben und sollte separat geplant werden. Das Build-System funktioniert noch, ist aber auf veralteten Node-Versionen abhängig.
|
||||
|
||||
---
|
||||
|
||||
### ℹ️ `BaseRepository.php` — fehlende Return-Types
|
||||
|
||||
**Datei:** `app/Repositories/BaseRepository.php`
|
||||
|
||||
Alle Methoden haben keine Return-Type-Deklarationen. Als technische Schuld für künftige Refactoring-Sessions notiert.
|
||||
|
||||
---
|
||||
|
||||
### ℹ️ `phpunit.xml` — SQLite & zweite Datenbankverbindung
|
||||
|
||||
Tests nutzen SQLite (`:memory:`), die `mysql_stern`-Verbindung ist nicht konfiguriert. Models, die `mysql_stern` nutzen (`App\Models\Sym\*`, `App\Models\TravelBooking`), können in Tests nicht ohne Mock/Skip getestet werden.
|
||||
|
||||
**Empfehlung:** Für Tests eine zweite SQLite-Verbindung in `phpunit.xml` konfigurieren:
|
||||
```xml
|
||||
<env name="DB_CONNECTION_STERN" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE_STERN" value=":memory:" force="true"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Neue Test-Dateien
|
||||
|
||||
| Datei | Typ | Was wird getestet |
|
||||
|-------|-----|-------------------|
|
||||
| `tests/Unit/Services/UtilTest.php` | Unit | `Util`-Service: Formatierung, Sanitizing, Placeholder-Ersetzung |
|
||||
| `tests/Feature/Auth/LoginTest.php` | Feature | Login, Logout, Redirect für Gäste/inaktive User |
|
||||
| `tests/Feature/Api/BookingImportTest.php` | Feature | API-Key-Validierung, 404-Handling |
|
||||
| `tests/Feature/BookingControllerTest.php` | Feature | Middleware (Guest-Redirect, Admin-Check) |
|
||||
|
||||
### Tests ausführen
|
||||
|
||||
```bash
|
||||
# Alle Tests
|
||||
php artisan test --compact
|
||||
|
||||
# Nur Unit-Tests
|
||||
php artisan test --compact tests/Unit
|
||||
|
||||
# Nur die neuen Feature-Tests
|
||||
php artisan test --compact tests/Feature/Auth/LoginTest.php
|
||||
php artisan test --compact tests/Feature/Api/BookingImportTest.php
|
||||
php artisan test --compact tests/Feature/BookingControllerTest.php
|
||||
|
||||
# Einzelner Test per Filter
|
||||
php artisan test --compact --filter=testLoginFailsWithWrongPassword
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte (Empfehlung)
|
||||
|
||||
1. **API-Key** aus Code entfernen → `.env`-Variable (HOCH)
|
||||
2. `MailDirService` extrahieren (Booking + Lead Services zusammenführen)
|
||||
3. `BookingController` auf Request-Injection umstellen
|
||||
4. `Util.php` Carbon-Calls bereinigen
|
||||
5. Leere Konstruktoren entfernen
|
||||
6. Weitere Factories erstellen (`Customer`, `Booking`, `Lead`) für tiefere Test-Abdeckung
|
||||
7. Frontend-Tooling-Update planen (laravel-mix → Vite oder mix v6)
|
||||
61
dev/briefings.md
Normal file
61
dev/briefings.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
Anfrage direkt vom Kunden
|
||||
|
||||
"Zudem habe ich ein wichtiges Anliegen. Wir möchten aus unserem System unbedingt einfach, schnell und qualitativ hochwertig Angebote erstellen können, die wir ja individuell ausarbeiten. Es ist also fast jedes der Angebote anders.
|
||||
|
||||
Derzeit bestehen unsere Angebote aus Texten in einer einfachen Email. Das finde ich unprofessionell, denn die Kunden haben damit nichts, was sie sich schön ausdrucken können, was übersichtlich wie ein vernünftiges Angebot aussieht. Wenn es dabei auch noch um sehr teure Reisen geht, passt das einfach nicht und zeugt nicht von Seriosität.
|
||||
|
||||
Daher möchte ich fragen, ob wir daran arbeiten können?
|
||||
|
||||
Theoretisch kann ein solches Angebot aussehen wie unser Buchungsauftrag, nur dass "Angebot" darüber steht (oder Persönliches Angebot für Frau XXX). Wichtig ist uns , dass es einfach zu erstellen ist und man ggf. bei einer sehr hochwertigen Reise auch weiterführenden Text zum Reiseverlauf für die Reisebeschreibung hinzufügen kann. Das kann aber ggf. auch auf einem separaten Briefbogen erfolgen, den wir dann hochladen.
|
||||
|
||||
Aktuell gehen wir so vor: Kunde ruft an, wir füllen unser Kontaktformular mit seinen Anfragedaten aus, sodass ein Kundendatensatz und eine Anfrage im System entsteht. Dann schreiben wir Emails hin und her, bis er sich für eine Buchung entscheidet und dann füllen wir das Buchungsformular aus. Daraus entsteht dann ein zweites mal der Kundendatensatz und der Buchungssatz.
|
||||
|
||||
An der Stelle, an der wie die Anfrage erstellen, möchten wir im System bereits die Möglichkeit haben, das Angebot zu erstellen. Vielleicht kann man hier aus einer der Vorlagen eine Organisation laden, dieses dann an die individuelle Anfrage anpassen und daraus dann das Angebot als Pdf erstellen. Anschließend wäre es gut, wenn hieraus direkt eine Buchung generiert werden könnte, denn dann haben wir ja schon alle Leistungen im System eingetragen."
|
||||
|
||||
|
||||
|
||||
Weitere wichtige Punkte, die in den Entwicklungsplan gehören:
|
||||
|
||||
Das System muss grundsätzlich an vielen Stellen modernisiert werden und optimiert werden, auch was Darstellung und Benutzer Bedienung angeht.
|
||||
|
||||
https://mein.sterntours.test/requests
|
||||
Ein wichtiger Punkt sind die Buchungen hier muss noch deutlich optimiert werden.
|
||||
|
||||
Derzeit kommen alle Buchungen über die Webseite Siehe /sterntours.de/src/AppBundle/Controller /sterntours.de/src/AppBundle/Export
|
||||
Es muss auch möglich sein, direkt im System Buchungen anzulegen. Hintergrund ist wenn derzeit eine Anfrage per Telefon kommt, gehen die Mitarbeiter tatsächlich auf die Webseite und lösen eine Bestellung aus, Das muss auch einfach im System funktionieren
|
||||
|
||||
Unter dem Punkt Buchungen muss Der Reiter organisation verbessert werden Es muss von der Benutzerführung einfacher und übersichtlicher werden, eine Organisation einer Reise anzulegen und zu verwalten.
|
||||
|
||||
Unter dem Punkt Buchungen gibt es E-Mails wird eine E-Mail geschrieben. Soll diese sofort per AutoSave als Entwurf gespeichert werden.
|
||||
|
||||
Zusätzlich gilt das, was hier noch aufgeschlüsselt ist mein.sterntours.de/dev/customer-bookings/*
|
||||
|
||||
Unter dem Punkt Fewos
|
||||
https://mein.sterntours.test/travel_user_booking_fewos
|
||||
|
||||
Auch hier müssen die E-Mails, die den selben Prinzip die Buchung verfolgen auch ein Auto Safe bekommen.
|
||||
Da die E-Mails im System grundsätzlich eine einheitliche Programmierung haben, wäre es sinnvoll, diese Skripte zu migrieren.
|
||||
|
||||
Bei den Buchungen der Ferienwohnungen tauchen immer wieder Fehler auf bei der Belegung das liegt daran, wenn die Daten geändert werden. Diese werden nicht synchron überschrieben, so dass Doppelbuchungen teilweise im System vorliegen. Ich glaube nur in der Datenbank das muss natürlich bereinigt werden.
|
||||
|
||||
Zusätzlich gibt es ein weiteres Backend sterntours.de/src/AppBundle/Controller/AdminController.php Dieses muss auch sauber in das Hauptbackend mein.sterntours.de (v3) Übernommen werden, damit wir das alte abstellen können. Damit wird sich dann vermutlich auch der Fehler erledigen. Grundsätzlich darf gerne die Datenstruktur migriert werden und optimiert werden.
|
||||
|
||||
Hier gibt es schon mal einen Ansatz für die Darstellung des Navigations,dieser ist aber noch nicht gut
|
||||
https://mein.sterntours.test/navigation-api
|
||||
|
||||
Im System gerade in Fronten sterntours.de/src/AppBundle/Listener/KernelControllerListener.php Ist die Struktur der Navigation und der des Seitenbaums sehr eigenwillig und muss verbessert und optimiert werden mein.sterntours.de/app/Models/Page.php Alles liegt in dieser Page Tabelle und ist sehr schlecht. War bei undurchschaubar hier muss ein deutlich Cleaneres konzept her. So dass auch hier https://mein.sterntours.test/navigation-api Übersichtlich ein Navigations Baum erstellt werden kann, der deutlich klarer zeigt, was auf dem Fronten zu sehen ist und wo ich welche Elemente ändern kann.
|
||||
|
||||
Teilweise gibt es schon CMS Ansätze, die die Fronten Zeiten bearbeitBar machen. Dieses ist hier in Kategorien geteilt.
|
||||
https://mein.sterntours.test/cms/feedback
|
||||
https://mein.sterntours.test/cms/fewo/content
|
||||
https://mein.sterntours.test/cms/travel_guide/content
|
||||
https://mein.sterntours.test/iq/content/tree/index
|
||||
https://mein.sterntours.test/cms/news
|
||||
https://mein.sterntours.test/cms/answer_question
|
||||
https://mein.sterntours.test/cms/sidebar
|
||||
https://mein.sterntours.test/cms/content/infos
|
||||
https://mein.sterntours.test/cms/content/all
|
||||
|
||||
Grundsätzlich muss das alles mehr vereinheitlicht werden und deutlicher werden, was, wo geändert werden kann. Die Einzelmodule können so bleiben. Es muss nur klarer in der Benutzerführung werden und auch aus der Hauptnavigation erreichbar sein und deutlich sein, wo was hingehört.
|
||||
|
||||
Jetzt kommt noch ein sehr großer Punkt und zwar gibt es noch ein Verwaltung v2.stern-tours.de/application/controllers/acp Hier werden Reisen angelegt und auch die Reisezeiträume verwaltet. Dieses ist mittlerweile absolut veraltet und auch Fehler anfällig. Ein großes neues Modul wird es sein dieses auf die bestehende mein.sterntours.de (v3) Zu migrieren d.h. der komplette Funktionsumfang muss in das Back and ein entwickelt werden und benutzerfreundliche gemacht werden sowie müssen die gesamten Skripte etc. deutlich optimiert werden.
|
||||
441
dev/customer-bookings/konzept.md
Normal file
441
dev/customer-bookings/konzept.md
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# 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.
|
||||
```
|
||||
464
dev/customer-bookings/umsetzung.md
Normal file
464
dev/customer-bookings/umsetzung.md
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# Umsetzung: Neustrukturierung Customer / Lead / Booking
|
||||
|
||||
**Status:** Phase 1 auf Testsystem abgeschlossen — Contacts-Modul live; **Phase-2-App-Code vorbereitet (deploy-bereit)**
|
||||
**Erstellt:** April 2025
|
||||
**Konzept:** [konzept.md](konzept.md)
|
||||
|
||||
---
|
||||
|
||||
## Aktueller Fortschritt
|
||||
|
||||
| Phase | Status | Deployed auf Test? | Deployed auf Live? |
|
||||
|-------|--------|-------------------|-------------------|
|
||||
| Phase 1 — Contact-Deduplizierung | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
|
||||
| Phase 1 — Contacts-Modul (neuer Code) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
|
||||
| Phase 2 — App-Code (Models/Repos/Controller/Views auf `contacts`/`inquiries`/`inquiry_id`) | ✅ Abgeschlossen | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ⬜ Ausstehend (Code ist deploy-ready) | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
|
||||
|
||||
### Was in Phase 1 umgesetzt wurde
|
||||
|
||||
**Datenbank-Migrationen (alle auf Testsystem eingespielt):**
|
||||
- `merged_into_id` + `merged_at` auf `customer`-Tabelle → Duplikat-Tracking
|
||||
- `deleted_at` auf `customer`-Tabelle → Soft Delete für neue Contacts-UI
|
||||
|
||||
**Artisan Commands:**
|
||||
- `contacts:find-duplicates` — findet Duplikate nach E-Mail / Name+Geburtsdatum / Name+PLZ
|
||||
- `contacts:merge-duplicates` — führt Duplikate zusammen (dry-run-Modus vorhanden)
|
||||
|
||||
**Contacts-Modul (neuer paralleler Code, alter Code bleibt unberührt):**
|
||||
- `app/Models/Contact.php` — Global Scope schließt Duplikate + gelöschte Kontakte aus
|
||||
- `app/Repositories/ContactRepository.php`
|
||||
- `app/Http/Controllers/ContactController.php` — index, detail, store, destroy, getContacts
|
||||
- `resources/views/contact/` — index, detail, partials
|
||||
- Neue Routes unter `/contacts` und `/contact/*`
|
||||
- Neuer Navigationspunkt "Kontakte" im Sidenav
|
||||
|
||||
**Contacts-Übersicht — Features:**
|
||||
- DataTable mit Schnellsuche (Name, Vorname, E-Mail, Telefon gleichzeitig)
|
||||
- PLZ/Ort-Suche mit OR-Logik über beide Felder
|
||||
- Schnellfilter: Alle / Mit Anfragen / Mit Buchungen
|
||||
- Anfragen- und Buchungs-Zähler pro Kontakt als klickbare Badges
|
||||
- History-Modal: Klick auf Anfragen- oder Buchungs-Badge öffnet Modal mit vollständiger Verlaufsübersicht (AJAX, `_detail_history.blade.php` mit `$modal=true`, Links öffnen in neuem Tab)
|
||||
- Löschen mit Bootstrap-Bestätigungs-Modal + Fehler-Toast (blockiert wenn Anfragen/Buchungen vorhanden)
|
||||
|
||||
---
|
||||
|
||||
## Übersicht der Migrations-Dateien
|
||||
|
||||
Alle Migrations-Dateien liegen in `database/migrations/` und können gezielt auf den Live-Server eingespielt werden. Jede Phase ist **unabhängig deploybar** — das System bleibt nach jeder Phase voll funktionsfähig.
|
||||
|
||||
| Datei | Phase | Beschreibung |
|
||||
|-------|-------|-------------|
|
||||
| `2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php` | 1 | `merged_into_id` + `merged_at` zu `customer` hinzufügen |
|
||||
| `2025_04_15_100002_phase1_add_soft_delete_to_customer_table.php` | 1 | `deleted_at` (Soft Delete) zu `customer` hinzufügen |
|
||||
| `2025_04_15_200001_phase2_rename_customer_to_contacts.php` | 2 | `customer` → `contacts` umbenennen |
|
||||
| `2025_04_15_200002_phase2_rename_lead_to_inquiries.php` | 2 | `lead` → `inquiries` umbenennen |
|
||||
| `2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php` | 2 | `booking.lead_id` → `booking.inquiry_id` |
|
||||
| `2025_04_15_300001_phase3_create_participants_unified_table.php` | 3 | `participants_unified` erstellen + Daten migrieren |
|
||||
| `2025_04_15_300002_phase3_drop_old_participant_tables.php` | 3 | `lead_participant` + `participant` droppen (**nach Test!**) |
|
||||
| `2025_04_15_400001_phase4_create_communications_table.php` | 4 | `communications` erstellen + Daten migrieren |
|
||||
| `2025_04_15_400002_phase4_create_notices_table.php` | 4 | `notices` erstellen + Daten migrieren |
|
||||
| `2025_04_15_400003_phase4_create_attachments_table.php` | 4 | `attachments` erstellen + Daten migrieren |
|
||||
| `2025_04_15_400004_phase4_drop_old_communication_tables.php` | 4 | `lead_mails` + `customer_mails` droppen (**nach Test!**) |
|
||||
| `2025_04_15_400005_phase4_drop_old_notice_tables.php` | 4 | `lead_notices` + `booking_notices` droppen (**nach Test!**) |
|
||||
| `2025_04_15_400006_phase4_drop_old_attachment_tables.php` | 4 | `lead_files` + `booking_files` droppen (**nach Test!**) |
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
```bash
|
||||
# Auf dem Live-Server ausführen:
|
||||
# 1. Datenbank-Backup erstellen (vor JEDER Phase!)
|
||||
mysqldump -u root -ppassword stern_crm > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 2. Migration-Status prüfen
|
||||
php artisan migrate:status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Contact-Deduplizierung
|
||||
|
||||
### Schritt 1: Migration einspielen
|
||||
```bash
|
||||
php artisan migrate --path=database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_100002_phase1_add_soft_delete_to_customer_table.php
|
||||
```
|
||||
|
||||
### Schritt 2: Duplikate analysieren (dry-run)
|
||||
```bash
|
||||
php artisan contacts:find-duplicates
|
||||
# Optional: CSV exportieren
|
||||
php artisan contacts:find-duplicates --export=storage/app/duplicates.csv
|
||||
# Nur bestimmte Konfidenz-Stufe
|
||||
php artisan contacts:find-duplicates --confidence=HIGH
|
||||
```
|
||||
|
||||
### Schritt 3: Duplikate zusammenführen
|
||||
```bash
|
||||
# Erst Vorschau ohne Änderungen
|
||||
php artisan contacts:merge-duplicates --dry-run
|
||||
|
||||
# Dann ausführen (HIGH-Konfidenz zuerst, sicherste Duplikate)
|
||||
php artisan contacts:merge-duplicates --confidence=HIGH --force
|
||||
php artisan contacts:merge-duplicates --confidence=MEDIUM --force
|
||||
|
||||
# LOW nur nach manueller Prüfung der CSV
|
||||
php artisan contacts:merge-duplicates --confidence=LOW --force
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung
|
||||
```sql
|
||||
-- Wie viele Duplikate wurden zusammengeführt?
|
||||
SELECT COUNT(*) FROM customer WHERE merged_into_id IS NOT NULL;
|
||||
|
||||
-- Gibt es noch aktive Duplikate (gleiche E-Mail)?
|
||||
SELECT email, COUNT(*) FROM customer
|
||||
WHERE merged_into_id IS NULL AND email IS NOT NULL AND email != ''
|
||||
GROUP BY email HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
### Rollback Phase 1
|
||||
```bash
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Tabellen umbenennen
|
||||
|
||||
> **Wichtig:** Vor Phase 2 muss der App-Code bereits auf die neuen Tabellennamen vorbereitet sein,
|
||||
> ODER die Migration wird deployed bevor der Code-Release erfolgt (mit sofortigem Rollback-Plan).
|
||||
>
|
||||
> **Der App-Code ist vorbereitet** (siehe Abschnitt "Erledigte Code-Änderungen"). Empfehlung
|
||||
> für den Live-Deploy: **Maintenance-Mode-Window** — Code-Release und Migrationen atomar
|
||||
> in einem Wartungsfenster einspielen, damit Code und DB immer synchron sind.
|
||||
|
||||
### Erledigte Code-Änderungen (App-Code ist Phase-2-ready)
|
||||
|
||||
**Models (`app/Models/`):**
|
||||
- `Customer.php`: `protected $table = 'contacts';` (mit Doku-Kommentar zu Modul 3 Phase 2)
|
||||
- `Lead.php`: `protected $table = 'inquiries';` (mit Doku-Kommentar — Model-Name bleibt aus Kompatibilitätsgründen)
|
||||
- `Contact.php`: `protected $table = 'contacts';` (ohne Fallback-Kommentar zu "pre-Phase-2")
|
||||
- `Booking.php`:
|
||||
- `@property int $inquiry_id` (ersetzt `$lead_id`)
|
||||
- `$casts['inquiry_id']` + `$fillable` enthalten `'inquiry_id'` statt `'lead_id'`
|
||||
- `lead()`-Relation: `return $this->belongsTo(Lead::class, 'inquiry_id');` — Methodenname bleibt für Legacy-Kompatibilität
|
||||
- Zusätzlich `inquiry()`-Alias-Relation für semantische Klarheit im neuen Code
|
||||
|
||||
**Repositories (`app/Repositories/`):**
|
||||
- `BookingPDFRepository.php`: alle 9 Vorkommen von `$this->model->lead_id` → `$this->model->inquiry_id`. In `booking_documents.lead_id` (Shadow-Feld, Spaltenname bleibt) wird jetzt `$this->model->inquiry_id` geschrieben.
|
||||
- `LeadRepository::createBooking()`: Booking-Create nutzt `'inquiry_id' => $this->model->id`.
|
||||
- `CustomerMailRepository`: `customer_mails.lead_id`-Spalte bleibt erhalten, Wert kommt aus `$booking->inquiry_id`. Alle 6 Vorkommen angepasst.
|
||||
|
||||
**Controllers + Services + Commands:**
|
||||
- `RequestController.php`: alle Booking-Queries `where('lead_id', ...)` → `where('inquiry_id', ...)`; Datatables-SQL-Sort auf `inquiry_id`.
|
||||
- `API/BookingController.php`: API-Feldname `lead_id` bleibt (Abwärtskompatibilität), Wert aus `$booking->inquiry_id`.
|
||||
- `Admin/ReportController.php` + `Admin/ReportProviderController.php`: alle 16 Vorkommen von `$v->booking->lead_id` / `$export->booking->lead_id` → `...->inquiry_id`.
|
||||
- `Admin/ReportLeadsController.php` + `LeadController.php`: qualifizierte Subquery `whereColumn('lead_id', 'lead.id')` → `whereColumn('lead_id', 'inquiries.id')`.
|
||||
- `ContactController.php`: `select('customer.*')` → `select('contacts.*')`, `where('customer.id', ...)` → `where('contacts.id', ...)`, `DB::table('customer')` / `DB::table('lead')` → `DB::table('contacts')` / `DB::table('inquiries')`.
|
||||
- `CustomerController.php`: `select('customer.*')` → `select('contacts.*')`.
|
||||
- `Services/BookingImport.php`: Booking-Create-Array nutzt `'inquiry_id'`.
|
||||
- `Console/Commands/ContactsMergeDuplicates.php` + `ContactsFindDuplicates.php`: alle `DB::table('customer')` / `DB::table('lead')` umgestellt.
|
||||
- `Console/Commands/SyncNewsletterKulturreisen.php`: `metadata['lead_id']`-Key bleibt (ist Payload-Konvention), Wert aus `$booking->inquiry_id`.
|
||||
|
||||
**Views (`resources/views/`):**
|
||||
- `pdf/components/booking_header.blade.php`, `pdf/components/booking_head.blade.php`, `customer/mail/modal-show-mail-inner.blade.php`: `{{ $booking->lead_id }}` → `{{ $booking->inquiry_id }}`.
|
||||
- Alle weiteren View-Treffer zu `lead_id` sind Lead-Kontext (`lead_mails.lead_id`, `lead_notices.lead_id`, HTML-Data-Attribute) und bleiben unverändert — die Spalten werden von Phase 2 nicht umbenannt.
|
||||
|
||||
**Was _nicht_ umgestellt wurde (mit Absicht):**
|
||||
- FK-Spalten `lead_mails.lead_id`, `lead_notices.lead_id`, `lead_files.lead_id`, `lead_participant.lead_id`, `inquiry.lead_id`, `status_history.lead_id`, `customer_mails.lead_id`, `booking_documents.lead_id` — Spaltennamen bleiben, FKs zeigen nach `RENAME TABLE` automatisch auf `inquiries.id`.
|
||||
- API-Feld `lead_id` in `API/BookingController::import`-Response — Abwärtskompatibilität für API-Konsumenten.
|
||||
- Metadaten-Keys `lead_id` in Newsletter-Payload.
|
||||
- HTML-Data-Attribute `data-lead_id` + DataTables-Spaltennamen `lead_id` (UI-seitige Konventionen, kein DB-Bezug).
|
||||
- Routen-Pfade `/lead/*` — reine UX-Arbeit, nicht blockierend für DB-Rename. Nachträglich als 301-Redirect-Paket umsetzbar.
|
||||
- Routen-Namen (`lead_detail`, `lead_index`) — bleiben als Aliase, um Links in Views/Mails/Logs nicht zu brechen.
|
||||
|
||||
### Schritt 1: Migrationen einspielen
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung
|
||||
```sql
|
||||
-- Tabellen vorhanden?
|
||||
SHOW TABLES LIKE 'contacts';
|
||||
SHOW TABLES LIKE 'inquiries';
|
||||
-- Spalte umbenannt?
|
||||
SHOW COLUMNS FROM booking LIKE 'inquiry_id';
|
||||
-- FK vorhanden?
|
||||
SELECT * FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_NAME = 'booking' AND COLUMN_NAME = 'inquiry_id';
|
||||
```
|
||||
|
||||
### Rollback Phase 2
|
||||
```bash
|
||||
# In umgekehrter Reihenfolge
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Participants konsolidieren
|
||||
|
||||
### Schritt 1: Neue Tabelle erstellen + Daten migrieren
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
php artisan migrate --path=database/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung (vor Schritt 2!)
|
||||
```sql
|
||||
-- Zeilenzahlen vergleichen
|
||||
SELECT 'lead_participant' AS src, COUNT(*) AS cnt FROM lead_participant
|
||||
UNION ALL
|
||||
SELECT 'participant', COUNT(*) FROM participant
|
||||
UNION ALL
|
||||
SELECT 'participants_unified (inquiry)', COUNT(*) FROM participants_unified WHERE inquiry_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'participants_unified (booking)', COUNT(*) FROM participants_unified WHERE booking_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'participants_unified (lead_contact)', COUNT(*) FROM participants_unified WHERE is_lead_contact = 1;
|
||||
|
||||
-- Stichprobe: Vergleich einzelner Datensätze
|
||||
SELECT * FROM lead_participant LIMIT 5;
|
||||
SELECT * FROM participants_unified WHERE inquiry_id IS NOT NULL LIMIT 5;
|
||||
```
|
||||
|
||||
### Schritt 2: Alte Tabellen droppen (erst nach erfolgreicher Prüfung!)
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
php artisan migrate --path=database/migrations/2025_04_15_300002_phase3_drop_old_participant_tables.php
|
||||
```
|
||||
|
||||
> **Achtung:** Schritt 2 ist irreversibel. Nur ausführen wenn:
|
||||
> - participants_unified seit mindestens 1 Woche stabil läuft
|
||||
> - Alle PDF-Generierungen, Buchungsbestätigungen etc. korrekt funktionieren
|
||||
> - Kein Rollback auf Schritt 2 geplant
|
||||
|
||||
### Rollback Phase 3
|
||||
```bash
|
||||
# Nur Schritt 1 rollback-fähig:
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php
|
||||
# Schritt 2 ist irreversibel → Backup einspielen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Communications / Notices / Attachments konsolidieren
|
||||
|
||||
> **Voraussetzung:** Phase 2 muss abgeschlossen sein (inquiries + contacts Tabellen müssen existieren).
|
||||
|
||||
### Schritt 1: Neue Tabellen erstellen + Daten migrieren
|
||||
|
||||
Die drei Schritt-1-Migrationen sind voneinander **nicht** unabhängig:
|
||||
- `400003_attachments` setzt `400001_communications` voraus (wegen `communication_id` FK)
|
||||
- Reihenfolge daher: communications → notices → attachments
|
||||
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400001_phase4_create_communications_table.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400002_phase4_create_notices_table.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400003_phase4_create_attachments_table.php
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung Communications
|
||||
```sql
|
||||
SELECT 'lead_mails' AS src, COUNT(*) FROM lead_mails
|
||||
UNION ALL
|
||||
SELECT 'customer_mails', COUNT(*) FROM customer_mails
|
||||
UNION ALL
|
||||
SELECT 'communications (lead)', COUNT(*) FROM communications WHERE legacy_source = 'lead_mail'
|
||||
UNION ALL
|
||||
SELECT 'communications (cust)', COUNT(*) FROM communications WHERE legacy_source = 'customer_mail';
|
||||
|
||||
-- Reply-Chain korrekt?
|
||||
SELECT COUNT(*) FROM communications WHERE reply_id IS NOT NULL;
|
||||
-- Muss gleich sein wie:
|
||||
SELECT COUNT(*) FROM lead_mails WHERE reply_id IS NOT NULL;
|
||||
-- + COUNT(*) FROM customer_mails WHERE reply_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung Notices
|
||||
```sql
|
||||
SELECT 'lead_notices' AS src, COUNT(*) FROM lead_notices
|
||||
UNION ALL
|
||||
SELECT 'booking_notices', COUNT(*) FROM booking_notices
|
||||
UNION ALL
|
||||
SELECT 'notices (inquiry)', COUNT(*) FROM notices WHERE inquiry_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'notices (booking)', COUNT(*) FROM notices WHERE booking_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Ergebnis-Prüfung Attachments
|
||||
```sql
|
||||
SELECT 'lead_files' AS src, COUNT(*) FROM lead_files
|
||||
UNION ALL
|
||||
SELECT 'booking_files', COUNT(*) FROM booking_files
|
||||
UNION ALL
|
||||
SELECT 'attachments (lead)', COUNT(*) FROM attachments WHERE legacy_source = 'lead_file'
|
||||
UNION ALL
|
||||
SELECT 'attachments (booking)', COUNT(*) FROM attachments WHERE legacy_source = 'booking_file';
|
||||
|
||||
-- communication_id korrekt verknüpft?
|
||||
SELECT COUNT(*) FROM lead_files WHERE lead_mail_id IS NOT NULL;
|
||||
-- Muss gleich sein wie:
|
||||
SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file' AND communication_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Schritt 2: Alte Tabellen droppen (erst nach erfolgreicher Prüfung!)
|
||||
|
||||
Kann unabhängig pro Tabellenpaar eingespielt werden:
|
||||
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
# Communications droppen (achtet auf FK von lead_files → lead_mails wird intern behandelt):
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400004_phase4_drop_old_communication_tables.php
|
||||
|
||||
# Notices droppen:
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400005_phase4_drop_old_notice_tables.php
|
||||
|
||||
# Attachments droppen:
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400006_phase4_drop_old_attachment_tables.php
|
||||
```
|
||||
|
||||
> **Achtung:** Reihenfolge bei Schritt 2:
|
||||
> `400004_communications` muss vor `400006_attachments` laufen,
|
||||
> da `lead_files.lead_mail_id` FK auf `lead_mails` zeigt und erst in 400004 entfernt wird.
|
||||
> (Der FK wird in 400004 automatisch entfernt falls noch vorhanden.)
|
||||
|
||||
### Rollback Phase 4
|
||||
```bash
|
||||
# Schritt 1 rollback (umgekehrte Reihenfolge):
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_400003_phase4_create_attachments_table.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_400002_phase4_create_notices_table.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_400001_phase4_create_communications_table.php
|
||||
# Schritt 2 ist irreversibel → Backup einspielen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständige Deployment-Sequenz (alle Phasen auf einmal)
|
||||
|
||||
Nur empfohlen wenn alle Phasen bereits lokal/staging getestet wurden:
|
||||
|
||||
```bash
|
||||
# 1. Backup
|
||||
mysqldump -u [user] -p stern_crm > backup_pre_migration_$(date +%Y%m%d).sql
|
||||
|
||||
# 2. Maintenance Mode aktivieren
|
||||
php artisan down --render="errors::503"
|
||||
|
||||
# 3. Phase 1 — Deduplizierungsfelder
|
||||
php artisan migrate --path=database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php
|
||||
|
||||
# 4. Phase 1 — Duplikate zusammenführen
|
||||
php artisan contacts:merge-duplicates --confidence=HIGH --force
|
||||
php artisan contacts:merge-duplicates --confidence=MEDIUM --force
|
||||
|
||||
# 5. Phase 2 — Umbenennung
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
|
||||
# 6. Phase 3 — Participants
|
||||
php artisan migrate --path=database/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php
|
||||
|
||||
# 7. Phase 4 — Communications / Notices / Attachments
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400001_phase4_create_communications_table.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400002_phase4_create_notices_table.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_400003_phase4_create_attachments_table.php
|
||||
|
||||
# 8. Maintenance Mode deaktivieren
|
||||
php artisan up
|
||||
|
||||
# 9. Testen — BEVOR die Cleanup-Migrationen laufen!
|
||||
# → Daten prüfen (SQL-Queries oben), App manuell testen
|
||||
|
||||
# Cleanup-Migrationen (300002, 400004-400006) SEPARAT nach Testphase einspielen!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten zwischen Phasen
|
||||
|
||||
```
|
||||
Phase 1 ──────────────────────────────────────► unabhängig
|
||||
Phase 2 ──────────────────────────────────────► unabhängig (empfohlen nach Phase 1)
|
||||
Phase 3 ──────────► setzt Phase 2 voraus (FK auf inquiries)
|
||||
Phase 4a (comm) ──► setzt Phase 2 voraus (FK auf inquiries + contacts)
|
||||
Phase 4b (not.) ──► setzt Phase 2 voraus (FK auf inquiries)
|
||||
Phase 4c (att.) ──► setzt Phase 2 + Phase 4a voraus (FK auf communications)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback-Strategie: Zusammenfassung
|
||||
|
||||
| Migration | Rollback möglich? | Methode |
|
||||
|-----------|-------------------|---------|
|
||||
| Phase 1 (merge fields) | Ja | `migrate:rollback` |
|
||||
| Phase 2 (rename) | Ja | `migrate:rollback` (umgekehrte Reihenfolge) |
|
||||
| Phase 3 Schritt 1 (create participants_unified) | Ja | `migrate:rollback` |
|
||||
| Phase 3 Schritt 2 (drop participant tables) | **NEIN** | Backup einspielen |
|
||||
| Phase 4 Schritt 1 (create comm/notices/attach) | Ja | `migrate:rollback` (umgekehrte Reihenfolge) |
|
||||
| Phase 4 Schritt 2 (drop old tables) | **NEIN** | Backup einspielen |
|
||||
|
||||
**Faustregel:** Alle `_create_*` Migrationen sind rollback-fähig. Alle `_drop_*` Migrationen sind irreversibel.
|
||||
|
||||
---
|
||||
|
||||
## Abhängiges Modul: Newsletter (/newsletter)
|
||||
|
||||
Das Newsletter-Modul (`app/Http/Controllers/NewsletterController.php`) ist **nicht direkt betroffen** von den bisherigen Änderungen, muss aber bei zukünftigen Phasen im Blick behalten werden:
|
||||
|
||||
- Eigene Tabelle `newsletter_contacts` mit `customer_id` FK → `customer.id`
|
||||
- Verwendet `App\Models\Customer` (altes Model) über die `customer()`-Beziehung
|
||||
- Export (`/newsletter/export`) läuft über `NewsletterExport` → `NewsletterContact`-Model, nicht direkt über `customer`
|
||||
- Sync-Commands: `contacts:sync-newsletter-kulturreisen`, `contacts:sync-newsletter-ferienwohnungen`
|
||||
|
||||
### Was bei Phase 2 angepasst werden muss
|
||||
|
||||
Wenn `customer` → `contacts` umbenannt wird (Phase 2), muss im Newsletter-Modul:
|
||||
- `App\Models\Customer` → weiterhin verwenden ODER auf `App\Models\Contact` umstellen
|
||||
- FK `newsletter_contacts.customer_id` zeigt weiterhin auf dieselbe Tabelle (jetzt `contacts`) — keine Migration nötig, da FK-Name sich nicht ändert, nur der Tabellenname
|
||||
|
||||
### Aktueller Status
|
||||
- ✅ Newsletter-Export funktioniert unverändert (Phase 1 hat nichts daran geändert)
|
||||
- ✅ Phase-2-Check: `NewsletterContact` belongs-to `Customer::class` — `Customer` verwendet jetzt `$table = 'contacts'`, der FK `newsletter_contacts.customer_id` bleibt stabil. Keine Code-Änderung im Newsletter-Modul nötig.
|
||||
|
||||
---
|
||||
|
||||
## Code-Änderungen nach den Migrationen
|
||||
|
||||
Die Migrationen erstellen nur die neuen Tabellen und migrieren die Daten.
|
||||
Der App-Code muss separat angepasst werden (nicht Teil der Migrations selbst):
|
||||
|
||||
### Nach Phase 2
|
||||
- ✅ `app/Models/Customer.php`: `protected $table = 'contacts';`
|
||||
- ✅ `app/Models/Lead.php`: `protected $table = 'inquiries';`
|
||||
- ✅ `app/Models/Contact.php`: `protected $table = 'contacts';`
|
||||
- ✅ `app/Models/Booking.php`: `lead_id` → `inquiry_id` (`$casts`, `$fillable`, `@property`, `lead()`-Relation mit expliziter FK-Spalte `inquiry_id`, neuer `inquiry()`-Alias)
|
||||
- ✅ Alle Repositories, Controllers, Services, Commands und Views im Booking-Kontext auf `inquiry_id` umgestellt
|
||||
- ✅ Raw-SQL (`DB::table('customer')` / `DB::table('lead')`, `select('customer.*')` / `select('lead.*')`, `whereColumn(..., 'lead.id')`) auf `contacts` / `inquiries` umgestellt
|
||||
- ⬜ Route-URLs: `/lead/` → `/inquiry/` (301 Redirects) — nachgelagert, nicht blockierend für DB-Rename
|
||||
|
||||
### Nach Phase 3
|
||||
- `LeadRepository::createBooking()`: Participants nicht mehr kopieren — bestehende `inquiry_id`-Einträge werden per `booking_id` ergänzt
|
||||
- PDF-Generierung: auf `participants_unified` umstellen statt `lead_participant`/`participant`
|
||||
- Nach Cleanup (Schritt 2): alle direkten Queries auf `lead_participant`/`participant` entfernen
|
||||
|
||||
### Nach Phase 4
|
||||
- `LeadMailRepository` + `CustomerMailRepository` → `CommunicationRepository` (neu erstellen)
|
||||
- `LeadFileRepository` + `BookingFileRepository` → `AttachmentRepository` (neu erstellen)
|
||||
- `LeadNoticeRepository` + `BookingNoticeRepository` → `NoticeRepository` (neu erstellen)
|
||||
- `MailDirService`: Datenzugriff auf `communications` umstellen
|
||||
- Views für Mails/Notizen/Dateien in Lead und Booking können nach Umstellung geteilt werden
|
||||
732
dev/entwicklungsplan.md
Normal file
732
dev/entwicklungsplan.md
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
# Entwicklungsplan mein.sterntours.de (2026)
|
||||
|
||||
**Erstellt:** April 2026
|
||||
**Basis:** [briefings.md](./briefings.md), [customer-bookings/](./customer-bookings/), [audit-april-2025.md](./audit-april-2025.md), [projekt-empfehlungen-2026-04.md](./projekt-empfehlungen-2026-04.md), [frontend-navigation/](./frontend-navigation/)
|
||||
**Ziel:** Konsolidierung der drei Anwendungen (`mein.sterntours.de` v3, `sterntours.de` Frontend-Backend, `v2.stern-tours.de` Reiseverwaltung) in **ein** modernes Laravel-10-CRM; Ablösung der Altsysteme; neue Kernfunktion „Angebote".
|
||||
|
||||
---
|
||||
|
||||
## 0. Einordnung und Grundprinzipien
|
||||
|
||||
### 0.1 Strategische Leitplanken
|
||||
|
||||
- **Ein System als Zielbild:** `mein.sterntours.de` (Laravel 10) wird das einzige Backend. `sterntours.de` bleibt als Frontend-Auslieferung bestehen, das Redaktions-/Admin-Backend darin entfällt. `v2.stern-tours.de` wird komplett abgelöst.
|
||||
- **Schrittweise, rückwärtskompatibel:** Jede Phase muss produktiv deploybar sein, ohne dass andere Module brechen. Parallelbetrieb alt/neu wo nötig.
|
||||
- **Migrationen immer mit Rollback:** Jede DB-Migration hat einen `down()`-Pfad, Drop-Migrationen laufen erst nach bestätigter Stabilität.
|
||||
- **UI-Baseline festlegen:** Bevor die Modernisierung in die Breite geht, wird ein verbindlicher UI/UX-Standard definiert (Listen, Detailseiten, Formulare, Modals, Toolbar, Filter, Inline-Edit, Tabs). Alle neuen Module werden daran ausgerichtet; Altmodule ziehen nach.
|
||||
- **Business-Logik in Services, nicht in Controllern.** Neue Module nutzen konsequent den bestehenden Schichtenaufbau (Controller → FormRequest → Repository → Service → Model).
|
||||
|
||||
### 0.2 Grob-Roadmap (Reihenfolge, high-level)
|
||||
|
||||
```
|
||||
Block A – Fundament (Voraussetzung für alles Weitere)
|
||||
A1 UI/UX-Baseline definieren
|
||||
A2 Technisches Aufräumen aus Audit (API-Key, Frontend-Tooling-Entscheidung, Pint/Larastan, Queue)
|
||||
A3 Customer/Lead/Booking-Neustrukturierung Phasen 2–4 abschließen
|
||||
|
||||
Block B – Kerngeschäft (sichtbare Verbesserungen für Mitarbeiter)
|
||||
B1 Backend-Direktbuchung (Reise + Fewo)
|
||||
B2 Organisations-Tab Buchung überarbeiten
|
||||
B3 E-Mail-System vereinheitlichen + Auto-Save
|
||||
B4 Fewo-Doppelbuchung / Belegungs-Bug beheben
|
||||
B5 Angebots-Modul (zentrale neue Funktion)
|
||||
|
||||
Block C – Ablösung der Altsysteme
|
||||
C1 Admin-Controller sterntours.de → mein.sterntours.de migrieren
|
||||
C2 Navigation + Page-Modell refactoren
|
||||
C3 CMS-Module vereinheitlichen (Navigation / Benutzerführung)
|
||||
C4 Reisenverwaltung v2.stern-tours.de migrieren (größtes Modul)
|
||||
|
||||
Block D – Betrieb & Langfristpflege
|
||||
D1 Tests, Monitoring, Deployment
|
||||
D2 Framework-/PHP-Upgrade-Pfad (Laravel 11, PHP 8.3+)
|
||||
|
||||
Block E – Kundenseitige Self-Service-Funktionen (Zukunftsmodul)
|
||||
E1 Kundenportal / Kunden-Login (Modul 15)
|
||||
```
|
||||
|
||||
Die genannten Module sind jeweils unten ausführlich beschrieben. Block A ist zwingende Voraussetzung, danach können B und C teilweise parallelisiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modul 1 — UI/UX-Baseline (Block A1)
|
||||
|
||||
**Ziel:** Einmalig festlegen, wie eine „moderne, einheitliche Seite" im Backend aussieht. Davon leitet sich alles andere ab.
|
||||
|
||||
### 1.1 Analyse / Inventar
|
||||
|
||||
- Screenshots aller Haupt-Listen (Buchungen, Anfragen, Fewo-Buchungen, Kunden, Kontakte, CMS-Listen) aufnehmen und Abweichungen dokumentieren (Filter oben / Filter als Sidebar / Suchfeld, Inline-Edit ja/nein, Pagination-Stil, Table-Buttons).
|
||||
- Detailseiten (Anfrage-Detail, Buchung-Detail, Fewo-Buchung-Detail) strukturell vergleichen: Welche Sektionen / Tabs / Boxen existieren, welche sind identisch, welche unterscheiden sich unnötig?
|
||||
- Typografie, Abstände, Form-Controls, Modal-Varianten auflisten.
|
||||
|
||||
### 1.2 Baseline-Definition
|
||||
|
||||
- Einheitliches Grid, einheitliche Toolbar (links Filter / Suche, rechts Aktionen).
|
||||
- Einheitliches DataTable-Pattern (Filter → Spalten → Badge-Logik → Zeilenaktionen).
|
||||
- Einheitliches Tab-Layout auf Detailseiten (linke Sidebar mit Stammdaten / Zähler, rechte Tab-Area).
|
||||
- Einheitliche Modals (Bestätigung, Formular, History).
|
||||
- Einheitliche Status-/Fehler-/Erfolgs-Toasts.
|
||||
- Komponentenbibliothek als Blade-Components unter `resources/views/components/ui/` (Button, Card, Tabs, Toolbar, FormRow, Modal, Toast).
|
||||
|
||||
### 1.3 Pilot-Umsetzung
|
||||
|
||||
- Bestehende **Contacts-Liste** (bereits gut, Phase 1 abgeschlossen) als Referenz auf den Baseline-Stand anheben.
|
||||
- Eine weitere Ansicht (Vorschlag: Buchungsliste) auf die Baseline portieren, um das Pattern zu validieren, bevor die Breite umgestellt wird.
|
||||
|
||||
### 1.4 Frontend-Tooling-Migration (parallel, blockiert den Pilot nicht)
|
||||
|
||||
**Zielkorridor (Entscheidung Abschnitt 17.7):** Vite + dart-sass + schrittweise Bootstrap 5.
|
||||
|
||||
- Laravel Mix 2 → **Vite** als Build-Tool.
|
||||
- `node-sass` → **dart-sass**.
|
||||
- **Bootstrap 4 → 5 schrittweise:** erst Infrastruktur umstellen (Vite/Sass), Bootstrap 5 als Ziel im gleichen Branch parallel einziehen; Views ziehen **modulweise** nach, nicht in einem Big-Bang-Rewrite.
|
||||
- Komponentenbibliothek aus 1.2 wird direkt auf Bootstrap 5 aufgesetzt — so zieht jedes neu gebaute Modul (ab Modul 4 aufwärts) automatisch in die neue Welt um.
|
||||
- Alte Views bleiben in Bootstrap 4 lauffähig, bis ihr jeweiliges Modul umgebaut wird.
|
||||
- Als eigener Feature-Branch, erst mergen, wenn Pilot-View auf neuer Pipeline läuft und parallel zu Bootstrap-4-Views koexistieren kann.
|
||||
|
||||
**Aufwand:** 2–3 Wochen (inkl. Komponenten-Library, Pilot-View).
|
||||
**Abhängigkeiten:** keine. Startmodul.
|
||||
**Deliverables:** Baseline-Dokument, Komponentenbibliothek, 2 Referenz-Views.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modul 2 — Technische Hausaufgaben (Block A2)
|
||||
|
||||
Aus [audit-april-2025.md](./audit-april-2025.md) und [projekt-empfehlungen-2026-04.md](./projekt-empfehlungen-2026-04.md), bevor größere Module starten. Alle klein / mittel, aber Voraussetzung für einen sauberen Weiterbau.
|
||||
|
||||
| # | Maßnahme | Priorität | Aufwand |
|
||||
|---|----------|-----------|---------|
|
||||
| 2.1 | Composer-Wildcards (`"*"`) durch feste Versionen ersetzen | Hoch | klein |
|
||||
| 2.2 | `navigation/cache/clear`-Endpoint absichern (Auth/Secret/IP) | Hoch | klein |
|
||||
| 2.3 | `booking/import`: GET entfernen, nur POST; Rate-Limit | Hoch | klein |
|
||||
| 2.4 | `MailDirService`-Nutzung in Booking-/Lead-Services konsolidieren (war im Audit offen) | Mittel | mittel |
|
||||
| 2.5 | API-Routen auf Klassen-Syntax (`[Controller::class, 'method']`) | Niedrig | klein, laufend |
|
||||
| 2.6 | Laravel Pint + Larastan + CI-Pipeline | Mittel | mittel |
|
||||
| 2.7 | PHPUnit: zweite SQLite-Connection für `mysql_stern` + Factories für Customer/Booking/Lead | Mittel | mittel |
|
||||
| 2.8 | Queue-Worker (Supervisor) + `Mail::queue()` für nicht-kritische Mails | Mittel | mittel–groß |
|
||||
| 2.9 | `Console\Kernel::schedule()` dokumentieren oder befüllen (keine unsichtbaren Crons) | Mittel | klein |
|
||||
|
||||
**Abhängigkeiten:** keine. Läuft parallel zu A1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Modul 3 — Customer / Lead / Booking konsolidieren (Block A3, in Arbeit)
|
||||
|
||||
**Aktueller Stand:** Phase 1 auf Testsystem abgeschlossen, Phasen 2–4 offen. Siehe [customer-bookings/umsetzung.md](./customer-bookings/umsetzung.md).
|
||||
|
||||
### 3.1 Was noch zu tun ist
|
||||
|
||||
1. **Phase 1 live deployen** (Duplikat-Merge). Voraussetzung: Backup + Stichprobenprüfung der CSV auf Test.
|
||||
2. **Phase 2 — Tabellen umbenennen** (`customer` → `contacts`, `lead` → `inquiries`, `booking.lead_id` → `inquiry_id`). Code-Vorarbeit: Model-`$table`, Repositories auf `inquiry_id`, Routen `/lead/` → `/inquiry/` mit 301-Redirects.
|
||||
3. **Phase 3 — Participants konsolidieren** (`participants_unified`). Code-Anpassung: `LeadRepository::createBooking()` kopiert keine Teilnehmer mehr; PDF-Generierung auf unified-Tabelle umstellen. **Vor dem Drop** der Alttabellen mindestens eine Woche Parallelbetrieb.
|
||||
4. **Phase 4 — Communications / Notices / Attachments konsolidieren.** Neue Repositories (`CommunicationRepository`, `NoticeRepository`, `AttachmentRepository`), Views teilen, dann Alt-Tabellen droppen.
|
||||
|
||||
### 3.2 Ergänzungen / Optimierungen, die in dieser Phase mitlaufen sollten
|
||||
|
||||
Aus eigener Analyse nachgeschoben:
|
||||
|
||||
- **Abhängiges Newsletter-Modul** (erwähnt in `umsetzung.md` §Abhängiges Modul): Nach Phase 2 `NewsletterContact`-Model auf `App\Models\Contact` mappen, damit wir keine zwei Model-Pfade für dieselbe Tabelle halten.
|
||||
- **Passport-/API-Scopes:** Nach Umbenennung der Tabellen sind alle API-Responses, die `customer_id`/`lead_id` ausliefern, zu prüfen (Versionierung `v1` behalten, optional `v2` mit neuen Feldnamen).
|
||||
- **Volltextsuche für Contacts:** Mit wachsender Kundenhistorie lohnt sich ein DB-Index auf `email`, `name`, `firstname`, `zip` (oder MySQL-Fulltext) — verbessert die bereits eingebaute Schnellsuche.
|
||||
- **Audit-Log für Merges:** `merge_log`-Tabelle (wer hat wann welchen Contact in welchen gemerged), damit im Support nachvollziehbar bleibt.
|
||||
- **DSGVO-Lösch-Workflow:** `deleted_at` existiert bereits. Braucht UI für „Kontakt komplett löschen" (mit allen Anfragen/Buchungen), inkl. Sperre, falls aktive Buchungen existieren.
|
||||
- **Teilnehmer als Stammdaten:** `participants_unified.contact_id` langfristig konsequent setzen — dann können Teilnehmer aus bisherigen Reisen bei neuer Buchung übernommen werden.
|
||||
|
||||
**Aufwand:** wie in `umsetzung.md` geschätzt, plus 1 Woche für die Ergänzungen oben.
|
||||
**Abhängigkeiten:** Phase 2 ist Voraussetzung für das neue Angebots-Modul (Modul 6), weil sonst zwei Namenswelten parallel leben.
|
||||
|
||||
---
|
||||
|
||||
## 4. Modul 4 — Backend-Direktbuchung (Block B1)
|
||||
|
||||
**Briefing-Punkt:** „Es muss auch möglich sein, direkt im System Buchungen anzulegen. Aktuell gehen Mitarbeiter auf die Webseite und lösen dort eine Bestellung aus."
|
||||
|
||||
### 4.1 Ist-Analyse
|
||||
|
||||
- Alle Buchungen laufen aktuell über das Webseiten-Formular (`sterntours.de/src/AppBundle/Controller` → API-Endpoint `booking/import` auf `mein.sterntours.de`).
|
||||
- Im Backend gibt es keinen vollständigen „Neue Buchung"-Flow; Anlage erfolgt bisher via Konvertierung aus einem Lead (`LeadRepository::createBooking()`).
|
||||
|
||||
### 4.2 Teilschritte
|
||||
|
||||
1. **Flow definieren (paper design):**
|
||||
- Einstieg 1: „Direktbuchung ohne Anfrage" (z. B. aus `/bookings` → „Neue Buchung").
|
||||
- Einstieg 2: „Buchung aus Angebot" (kommt aus Modul 6).
|
||||
- Einstieg 3: Bestehend — aus Anfrage (bleibt wie gehabt).
|
||||
2. **Gemeinsames BookingFormRequest:** Validierung, die sowohl von der Web-API als auch vom Backend-Form genutzt wird (Single Source of Truth).
|
||||
3. **BookingService::createManual(array $data, ?Inquiry $inquiry = null)** als zentrale Erzeugungsmethode (ersetzt Duplikatlogik zwischen Web-Import und Backend).
|
||||
4. **UI-Flow im Backend (mehrstufig, nicht ein Riesenformular):**
|
||||
- Schritt 1: Kontakt wählen oder anlegen (Contact-Autocomplete aus neuer Contacts-Tabelle).
|
||||
- Schritt 2: Reise / Fewo wählen, Termin, Zimmer-/Personenbelegung.
|
||||
- Schritt 3: Teilnehmer (aus Kontakt-Historie vorschlagen — hier greift `participants_unified.contact_id`).
|
||||
- Schritt 4: Leistungen / Optionen / Versicherung.
|
||||
- Schritt 5: Zahlmodalitäten, Anmerkungen.
|
||||
- Schritt 6: Übersicht + „Anlegen" + „Anlegen & Bestätigung-Mail senden".
|
||||
5. **Wiederverwendung des Web-Formulars:** Prüfen, ob Teile des Frontend-Formulars (`sterntours.de`) als Blade-Partials ins Backend gezogen werden können — sonst aber lieber neu auf der UI-Baseline, da das Alt-Frontend auf Twig/Symfony läuft.
|
||||
6. **E-Mail-Versand optional (Entscheidung Abschnitt 17.4):**
|
||||
- Letzter Schritt im Backend-Flow bietet zwei Buttons:
|
||||
- **„Anlegen & Bestätigung senden"** → nutzt dieselbe Mail-Pipeline wie der Web-Import (Buchungsbestätigung an Kunde + interne Mail).
|
||||
- **„Nur anlegen (keine Mail)"** → legt Buchung an, setzt ein internes Flag `silent_created = 1`, kein Kundenversand.
|
||||
- Das Flag wird im Audit-Log der Buchung vermerkt, damit später nachvollziehbar ist, warum keine Bestätigung rausging.
|
||||
- Nachträglicher manueller Mailversand bleibt jederzeit möglich (bestehende Mail-Funktionalität in Buchungs-Detailseite).
|
||||
7. **Dokumente wie bei Web-Buchung:** Direktbuchung nutzt dieselbe Dokumenten-Logik wie `app/Http/Controllers/BookingController.php` heute (zentral hinterlegte Dokumente über `hasDocument(...)` + freie Uploads über `BookingFileRepository`) — kein Neubau.
|
||||
|
||||
### 4.3 Fewo-Variante
|
||||
|
||||
- Dasselbe Konzept für Fewo (`TravelUserBookingFewoController`) — mit Verfügbarkeitsprüfung in Echtzeit und harter Kollisions-Sperre (siehe auch Modul 7, Doppelbuchungen).
|
||||
|
||||
**Aufwand:** ~4 Wochen (Reisebuchung + Fewo + Tests).
|
||||
**Abhängigkeiten:** UI-Baseline (Modul 1), Phase 2 Customer/Lead (damit `contact_id`/`inquiry_id` sauber sind).
|
||||
**Risiko:** Mittel — muss mit Web-API-Import identisch enden, sonst entstehen zwei Wahrheiten.
|
||||
|
||||
---
|
||||
|
||||
## 5. Modul 5 — Organisations-Tab der Buchung überarbeiten (Block B2)
|
||||
|
||||
**Briefing-Punkt:** „Unter dem Punkt Buchungen muss der Reiter Organisation verbessert werden. Es muss von der Benutzerführung einfacher und übersichtlicher werden, eine Organisation einer Reise anzulegen und zu verwalten."
|
||||
|
||||
### 5.1 Ist-Analyse (TODO vor Start des Moduls)
|
||||
|
||||
- Screenshots + Workflow-Walkthrough des aktuellen Organisations-Tabs dokumentieren.
|
||||
- Welche Entitäten / Datensätze hängen daran (Transportleistungen, Partner, Zeiten, Dokumente)? Welche Felder sind Pflicht, welche redundant?
|
||||
- Welche Reibungspunkte berichten die Mitarbeiter (Usability-Interview, 30 min).
|
||||
|
||||
### 5.2 Teilschritte
|
||||
|
||||
1. **Zieldesign** (aus UI-Baseline abgeleitet): Zeitleisten-/Kacheldarstellung je Leistungsposition (statt langer Tabelle), Inline-Edit für häufige Änderungen.
|
||||
2. **Refactor im Daten-Layer:** Doppelte Felder identifizieren, konsolidieren (vermutlich analog zu Teilnehmer-Konsolidierung).
|
||||
3. **UI-Umsetzung** auf Basis der neuen Komponentenbibliothek.
|
||||
4. **Bulk-Aktionen:** Leistungsblock kopieren (für Gruppen-/Serienreisen), Vorlagen speichern.
|
||||
5. **PDF-Output** prüfen (Organisationsplan als Dokument).
|
||||
|
||||
**Aufwand:** ~2–3 Wochen.
|
||||
**Abhängigkeiten:** Modul 1. Kein harter Zusammenhang zu Modul 3, aber wirkt sauberer, wenn Modul 3 Phase 3 (Participants) vorher durch ist.
|
||||
|
||||
---
|
||||
|
||||
## 6. Modul 6 — Angebote / Offers (Block B5, großer neuer Kernbaustein)
|
||||
|
||||
**Briefing-Zitat (Kunde):** „Wir möchten aus unserem System unbedingt einfach, schnell und qualitativ hochwertig Angebote erstellen können … Theoretisch kann ein solches Angebot aussehen wie unser Buchungsauftrag, nur dass ‚Angebot' darüber steht … Wichtig ist uns, dass es einfach zu erstellen ist und man ggf. bei einer sehr hochwertigen Reise auch weiterführenden Text zum Reiseverlauf für die Reisebeschreibung hinzufügen kann … Anschließend wäre es gut, wenn hieraus direkt eine Buchung generiert werden könnte."
|
||||
|
||||
### 6.1 Ziel
|
||||
|
||||
Aus einer Anfrage (oder direkt) ein **Angebot** erstellen, als PDF ausgeben, an den Kunden versenden und bei Annahme **mit einem Klick in eine Buchung umwandeln**, ohne Daten doppelt zu pflegen.
|
||||
|
||||
### 6.2 Datenmodell
|
||||
|
||||
Neue Tabellen (Entwurf):
|
||||
|
||||
```sql
|
||||
-- Logischer Angebotsdatensatz (eine "Angebots-Nummer", mehrere Versionen möglich)
|
||||
CREATE TABLE offers (
|
||||
id INT PK,
|
||||
inquiry_id INT NULL, -- aus Anfrage erzeugt
|
||||
contact_id INT NOT NULL,
|
||||
booking_id INT NULL, -- gesetzt, sobald Angebot → Buchung wurde
|
||||
offer_number VARCHAR UNIQUE, -- eigene Nummernkreisführung, z. B. 2026-00123
|
||||
status ENUM('draft','sent','accepted','declined','expired','withdrawn'),
|
||||
current_version_id INT NULL, -- FK auf offer_versions.id (aktiv/zuletzt versendet)
|
||||
created_by INT,
|
||||
created_at, updated_at
|
||||
);
|
||||
|
||||
-- Jede versendete (und jede danach geänderte) Fassung ist eine eigene Version
|
||||
CREATE TABLE offer_versions (
|
||||
id INT PK,
|
||||
offer_id INT FK,
|
||||
version_no INT, -- 1, 2, 3, ...
|
||||
status ENUM('draft','sent','accepted','declined','expired','superseded'),
|
||||
valid_until DATE NULL,
|
||||
total_price DECIMAL(10,2),
|
||||
headline VARCHAR, -- "Persönliches Angebot für Frau XXX"
|
||||
intro_text TEXT,
|
||||
itinerary_text LONGTEXT, -- Reiseverlauf, optional WYSIWYG
|
||||
closing_text TEXT,
|
||||
template_id INT NULL,
|
||||
pdf_path VARCHAR NULL, -- generiertes PDF dieser Version
|
||||
pdf_archived TINYINT(1) DEFAULT 0, -- für spätere Speicher-Bereinigung alter Versionen
|
||||
sent_at DATETIME NULL,
|
||||
accepted_at DATETIME NULL,
|
||||
accepted_via ENUM('customer_link','admin','email') NULL,
|
||||
created_by INT,
|
||||
created_at, updated_at,
|
||||
UNIQUE KEY (offer_id, version_no)
|
||||
);
|
||||
|
||||
CREATE TABLE offer_items (
|
||||
id INT PK,
|
||||
offer_version_id INT FK, -- gehört zu einer Version, nicht zum Angebot direkt
|
||||
position INT,
|
||||
type ENUM('travel','service','option','discount','insurance','custom'),
|
||||
title VARCHAR,
|
||||
description TEXT,
|
||||
quantity INT DEFAULT 1,
|
||||
price_per_unit DECIMAL(10,2),
|
||||
total_price DECIMAL(10,2),
|
||||
travel_program_id INT NULL,
|
||||
fewo_lodging_id INT NULL,
|
||||
metadata JSON
|
||||
);
|
||||
|
||||
CREATE TABLE offer_templates (
|
||||
id, name, description, headline, intro_text, itinerary_text, closing_text, items_json, ...
|
||||
);
|
||||
|
||||
-- Kundenseitiger Freigabe-Link (Token-URL, Einmal-Link pro Version)
|
||||
CREATE TABLE offer_access_tokens (
|
||||
id INT PK,
|
||||
offer_version_id INT FK,
|
||||
token VARCHAR(64) UNIQUE,
|
||||
expires_at DATETIME NULL,
|
||||
opened_at DATETIME NULL, -- wann der Kunde den Link erstmals geöffnet hat
|
||||
accepted_at DATETIME NULL,
|
||||
declined_at DATETIME NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL
|
||||
);
|
||||
```
|
||||
|
||||
Angebote sind **eigenständig**, nicht Unterdatensatz einer Buchung. Eine Buchung kann per `offer_id` rückverweisen, und ein Angebot hält per `booking_id` die daraus entstandene Buchung. Die Versionierung ist zwingend (Entscheidung aus Abschnitt 17.1): ab dem ersten Versand ist Änderung = neue Version.
|
||||
|
||||
### 6.3 Teilschritte
|
||||
|
||||
1. **Datenmodell & Migrationen** (`offers`, `offer_versions`, `offer_items`, `offer_templates`, `offer_access_tokens`).
|
||||
2. **Model / Repository / Service** (`OfferService::createFromInquiry($inquiryId)`, `::createNewVersion(Offer $offer)`, `::send(OfferVersion $v)`, `::convertToBooking(Offer $offer)`).
|
||||
3. **Vorlagen-Verwaltung:**
|
||||
- Eigene Liste `/offer-templates`, anlegbar aus einem existierenden Angebot (Button „Als Vorlage speichern").
|
||||
- Vorlagen pro Reise-Organisation gruppierbar (Briefing: „aus einer der Vorlagen eine Organisation laden").
|
||||
4. **Angebots-Editor:**
|
||||
- Einstieg: innerhalb `/inquiries/{id}` Button „Angebot erstellen"; alternativ eigenständiges `/offers/create`.
|
||||
- Vorlage wählen → Felder werden gefüllt → frei bearbeitbar.
|
||||
- Positionen (offer_items) mit Drag&Drop, Kopier-Button, Summe live.
|
||||
- Optionales Feld „Reiseverlauf" als WYSIWYG (TinyMCE/TipTap), ausblendbar für einfache Angebote.
|
||||
- **Dokumente am Angebot** (analog zum bestehenden Buchungs-Pattern in `app/Http/Controllers/BookingController.php`, der `booking.hasDocument('coupon'|'storno'|…)` für zentral hinterlegte Dokumente und `BookingFileRepository` für freie Uploads nutzt):
|
||||
- **Zentral hinterlegte Dokumente** (Briefbögen, AGB, Standard-Anhänge pro Reise-Organisation / Reisetyp) werden auswählbar und automatisch als Anhang angeboten.
|
||||
- **Freie Uploads pro Angebot** (individueller Reiseverlauf, eigener Briefbogen, Bilder) — analog zu `booking_file` über eine `offer_file`-Tabelle bzw. über das in Modul 3 Phase 4 konsolidierte `attachments`-Modul (dann mit `offer_version_id`).
|
||||
- Ab `sent` werden die zu diesem Zeitpunkt ausgewählten/hochgeladenen Dokumente mit der Version „eingefroren" (zur Version gespeichert, damit spätere Änderungen keine alten PDFs verfälschen).
|
||||
5. **PDF-Generierung:**
|
||||
- Neues Template `pdf/offer.blade.php` analog zum bestehenden Buchungsauftrag-PDF.
|
||||
- Header: „Angebot Nr. 2026-00123 / V2" und / oder „Persönliches Angebot für Frau Musterfrau".
|
||||
- Gleiche Corporate-Identity-Elemente wie Buchungsauftrag.
|
||||
- Anlage: zentral ausgewählte Dokumente + frei hochgeladene Dokumente.
|
||||
6. **Versionierung (fest verankert, Entscheidung Abschnitt 17.1):**
|
||||
- Solange Status `draft`: Änderungen ändern die aktuelle Version direkt.
|
||||
- Ab dem ersten `send`: jede Änderung erzeugt eine **neue Version** (V2, V3 …); vorherige Version wird `superseded`.
|
||||
- Jede Version hält ihr eigenes PDF + ihre eigenen Dokumente + ihre eigenen `offer_items` + ihren eigenen Freigabe-Token.
|
||||
- **Speicher-/Archivstrategie:** Cron-Job (später, Modul 13) setzt PDFs älterer Versionen `pdf_archived = 1` und verschiebt sie auf kalten Storage; konfigurierbares Retention-Limit (z. B. „nur letzte 3 Versionen im Hot-Storage, ältere PDFs bei Bedarf neu generieren").
|
||||
7. **Versand-Workflow:**
|
||||
- Status-Maschine auf `offer_versions`: `draft → sent → accepted/declined/expired/superseded`.
|
||||
- Versand als E-Mail (PDF im Anhang, Textvorlage im CMS pflegbar — siehe Modul 11).
|
||||
- Mail-Thread landet im gemeinsamen Communications-Modul (Modul 3 Phase 4).
|
||||
- Beim Senden wird pro Version **ein neuer `offer_access_token`** erzeugt und in die Mail als Link eingebaut.
|
||||
8. **Kundenseitiger Freigabe-Link (Entscheidung Abschnitt 17.2):**
|
||||
- Öffentliche Route `GET /angebot/{token}` auf `sterntours.de` (oder Subdomain `angebote.sterntours.de`), die die Angebotsversion anzeigt (PDF-Embed + Anhänge + Buttons „Annehmen" / „Ablehnen" / „Fragen?").
|
||||
- Route `POST /angebot/{token}/accept` und `POST /angebot/{token}/decline` mit IP/User-Agent-Protokoll.
|
||||
- Beim Annehmen: `offer_versions.status = accepted`, `accepted_at = now()`, `accepted_via = 'customer_link'`, Benachrichtigung (Mail + Dashboard-Hinweis) an den zuständigen Mitarbeiter.
|
||||
- **Admin-Annahme bleibt gleichwertig möglich** (Status händisch auf `accepted` setzen, `accepted_via = 'admin'`), z. B. bei telefonischer Zusage.
|
||||
9. **Konvertierung Angebot → Buchung:**
|
||||
- Button „In Buchung übernehmen" im Angebot (nur aktiv bei `accepted`).
|
||||
- `OfferService::convertToBooking()` legt Buchung + Teilnehmer + Leistungen auf Basis der angenommenen `offer_version` an; nutzt denselben Service wie Modul 4 (Direktbuchung), damit nur eine Erzeugungslogik existiert.
|
||||
- Angebots-Dokumente (Version-eingefroren) werden automatisch an die Buchung kopiert / verlinkt.
|
||||
- `booking_id` wird auf `offers` gesetzt.
|
||||
10. **Übersichtsseite / Listen:** `/offers` mit Filtern (Status, Ablaufdatum, Mitarbeiter, Kontakt, Version), Badges, Bulk-Export.
|
||||
11. **Zähler / Dashboard:** In der Kontakt- und Anfrage-Übersicht jeweils Badge „X Angebote" analog zum Anfragen-/Buchungs-Badge.
|
||||
12. **Berechtigungen:** Neue Permissions `offers.read`, `offers.create`, `offers.send`, `offers.accept`.
|
||||
|
||||
### 6.4 Ergänzungen aus meiner Sicht (nicht im Briefing, aber sinnvoll)
|
||||
|
||||
- **Ablauf-Automatik:** Cron setzt Angebote nach `valid_until` auf `expired`, optional Erinnerungsmail an Mitarbeiter vor Ablauf. Token wird dabei invalidiert.
|
||||
- **Änderbarkeits-Regel:** Nur `draft`-Versionen sind frei änderbar. Ab `sent` ist Änderung = neue Version.
|
||||
- **Preiskonsistenz:** `offer_items` greifen — sofern verknüpft — auf dieselben Preisquellen wie das bestehende Reise-/Fewo-System (aus v2 migriert, Modul 12). Keine Doppeltpflege von Preislisten.
|
||||
- **Anbindung Kundenportal (Modul 15):** Sobald das Kundenportal steht, sieht der eingeloggte Kunde seine Angebote direkt dort — der Token-Link bleibt aber für nicht eingeloggte Kunden weiterhin der Standardweg.
|
||||
|
||||
**Aufwand:** ~7–9 Wochen (großes Modul, durch Versionierung + Freigabe-Link leicht gewachsen).
|
||||
**Abhängigkeiten:**
|
||||
- UI-Baseline (Modul 1) muss stehen.
|
||||
- Modul 3 Phase 2 (inquiries-Tabelle), damit `inquiry_id`-Referenzen sauber sind.
|
||||
- Modul 3 Phase 4 (attachments-Tabelle) ideal — sonst legen wir eine separate `offer_file`-Tabelle an, die später fusioniert.
|
||||
- Modul 4 (gemeinsamer BookingService), damit die Konvertierung keinen Copy-Code erzeugt.
|
||||
- Modul 12 Teil „Reiseprogramme" verfügbar in Laravel, falls `offer_items` auf bestehende Programme referenzieren sollen — zur Not über View-basiertes Legacy-Lesemodell überbrückbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Modul 7 — E-Mail-System vereinheitlichen & Auto-Save (Block B3)
|
||||
|
||||
**Briefing-Punkte:**
|
||||
- „Unter dem Punkt Buchungen gibt es E-Mails … wird eine E-Mail geschrieben. Soll diese sofort per Auto-Save als Entwurf gespeichert werden."
|
||||
- „Auch hier [Fewo] müssen die E-Mails, die dem selben Prinzip die Buchung verfolgen, auch ein Auto-Save bekommen."
|
||||
- „Da die E-Mails im System grundsätzlich eine einheitliche Programmierung haben, wäre es sinnvoll, diese Skripte zu migrieren."
|
||||
|
||||
### 7.1 Teilschritte
|
||||
|
||||
1. **Konsolidierung zuerst erledigen** (Modul 3 Phase 4, `communications`-Tabelle). Solange drei verschiedene Mail-Tabellen existieren, ist jede Erweiterung doppelte Arbeit.
|
||||
2. **Draft-Feld einführen** in `communications`: Status `draft | queued | sent | failed`, plus `autosaved_at`.
|
||||
3. **Auto-Save im Editor (Frontend):**
|
||||
- JS-Debounce (alle 3 s) schreibt per `PATCH /communications/{id}/draft` Betreff und Body.
|
||||
- Anlage eines Drafts beim Öffnen des leeren Editors (liefert `id` zurück).
|
||||
- Crash-/Reload-Recovery: Beim Öffnen des Mail-Dialogs prüft das Frontend, ob ein bestehender Draft für diesen Kontext existiert (Anfrage / Buchung / Fewo-Buchung) und bietet ihn zum Weiterbearbeiten an.
|
||||
4. **Vereinheitlichte Modals** auf Basis der UI-Baseline (ein Mail-Modal für alle drei Kontexte).
|
||||
5. **Queue-basierter Versand** (siehe Modul 2 / 2.8): `Mail::queue()`, mit `send_after`-Feld (geplanter Versand), Retry-Logik.
|
||||
6. **CustomerFewoMail → Communications migrieren** (Ausnahme, die in Modul 3 Phase 4 ggf. noch offen ist).
|
||||
7. **Draft-Aufräumcron** (automatische Löschung von Drafts > 30 Tage ohne Aktivität).
|
||||
|
||||
**Aufwand:** ~2 Wochen nach Abschluss von Modul 3 Phase 4.
|
||||
**Abhängigkeiten:** Modul 3 Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## 8. Modul 8 — Fewo-Doppelbuchungen / Belegung fixen (Block B4)
|
||||
|
||||
**Briefing:** „Bei den Buchungen der Ferienwohnungen tauchen immer wieder Fehler auf bei der Belegung … wenn die Daten geändert werden … Doppelbuchungen teilweise im System vorliegen. Ich glaube nur in der Datenbank, das muss natürlich bereinigt werden."
|
||||
|
||||
### 8.1 Analyse
|
||||
|
||||
- Root-Cause vermutet (aus Briefing): Daten-Änderungen werden nicht synchron in die zweite Speicher-Ebene durchgeschrieben (vermutlich Legacy-Admin in `sterntours.de/AdminController.php` vs. `mein.sterntours.de`).
|
||||
- Ziel von Modul 9 (Admin-Migration) wird diesen Fehler indirekt mit beheben, der Bug muss aber separat gezielt adressiert werden — sonst blutet er weiter.
|
||||
|
||||
### 8.2 Teilschritte
|
||||
|
||||
1. **Quellen inventarisieren:** Welche Stellen schreiben `FewoReservation` / Belegungen (Legacy AdminController, Webseite, mein.sterntours Fewo-Buchungen, Fewo-Mail-Import, ggf. iCal-Sync)?
|
||||
2. **Konflikt-Detektor-Cron schreiben:** Stündlicher Job, der Überlappungen pro `fewo_lodging_id` findet und einen Report ins Dashboard + Slack/Mail liefert.
|
||||
3. **Datenbestand aufräumen:** Einmaliger Report → manuelle / halb-automatische Bereinigung.
|
||||
4. **Zentrale Schreiblogik einziehen:** Alle Fewo-Reservierungs-Anlagen / -Änderungen laufen künftig durch einen einzigen `FewoReservationService`, der
|
||||
- Überlappungen mit Lock prüft (`SELECT … FOR UPDATE`),
|
||||
- Konsistent in allen relevanten Tabellen schreibt,
|
||||
- Events feuert (für Cache-Invalidierung, Kalender).
|
||||
5. **Turnustag-Logik (Entscheidung Abschnitt 17.5):**
|
||||
- **Kein harter DB-Constraint** (kein `EXCLUSION`-/Unique-Index auf Tages-Ebene), weil An- und Abreisetag legitimerweise überlappen dürfen (Abreise-Vormittag / Anreise-Nachmittag am selben Tag).
|
||||
- Überlappung wird **im Service** nach klaren Regeln geprüft:
|
||||
- Konflikt = neue Buchung endet **später** als bestehende beginnt UND neue Buchung beginnt **früher** als bestehende endet, **und** die überlappenden Tage sind keine reinen Wechseltage (Turnustage).
|
||||
- „Wechseltag" konfigurierbar pro Lodging (Tagestyp: frei / nur Anreise / nur Abreise / gesperrt).
|
||||
- Der Service liefert bei Konflikt eine sprechende Fehlermeldung („Belegung kollidiert mit Buchung #1234 vom 12.–19.06., kein Wechseltag"), statt stumm abzulehnen.
|
||||
6. **Reservierungs-Audit-Log:** Jede Änderung protokollieren (alter/neuer Zeitraum, User, Quelle).
|
||||
7. **UI-Kalender für Mitarbeiter:** Visueller Belegungskalender pro Lodging mit farblich markierten Wechseltagen — macht das „überlappen darf, aber nur am Turnustag" für Mitarbeiter sofort sichtbar.
|
||||
|
||||
**Aufwand:** ~2 Wochen (Analyse + Fix + Bereinigung).
|
||||
**Abhängigkeiten:** kann früh gestartet werden; überschneidet sich inhaltlich mit Modul 9 (Admin-Migration) und muss dort mitgedacht werden.
|
||||
|
||||
---
|
||||
|
||||
## 9. Modul 9 — Migration Admin aus sterntours.de (Block C1)
|
||||
|
||||
**Briefing:** „Zusätzlich gibt es ein weiteres Backend `sterntours.de/src/AppBundle/Controller/AdminController.php`. Dieses muss auch sauber in das Hauptbackend `mein.sterntours.de` (v3) übernommen werden, damit wir das alte abstellen können. Damit wird sich dann vermutlich auch der Fehler erledigen. Grundsätzlich darf gerne die Datenstruktur migriert werden und optimiert werden."
|
||||
|
||||
### 9.1 Scope-Analyse
|
||||
|
||||
`AdminController.php` (~1.300 Zeilen) enthält laut Code u. a.:
|
||||
- Fewo-Lodging-Verwaltung (CRUD, Bilder, Gruppen).
|
||||
- Fewo-Preise, Saisons, Reservierungen.
|
||||
- Fewo-Booking-Requests.
|
||||
|
||||
### 9.2 Teilschritte
|
||||
|
||||
1. **Inventar:** Jede Route in `AdminController.php` (+ evtl. zugehörige Templates) auflisten, Funktion beschreiben, Äquivalent im Laravel-System suchen.
|
||||
2. **Datenstruktur-Analyse:** Welche Tabellen werden bedient (`fewo_lodging`, `fewo_price`, `fewo_season`, …)? Welche liegen schon in `mein.sterntours.de`, welche müssen migriert / umbenannt werden? Wo existiert doppelte Datenhaltung (Ursache für Modul 8)?
|
||||
3. **Zieldesign:** Pro Funktion ein Ticket/Epic (Fewo-Lodging-Verwaltung, Preise, Saisons, …). Jede Funktion zieht auf die UI-Baseline (Modul 1).
|
||||
4. **Migrationssequenz:**
|
||||
- a) Read-Only-Schattenansicht in `mein.sterntours.de` bauen, die Daten aus der bestehenden Tabelle anzeigt.
|
||||
- b) Schreib-Operationen parallel einbauen (Dual-Write-Modus: Legacy + neu), solange beide laufen.
|
||||
- c) Schreib-Rechte im Legacy-Admin entziehen, sobald die neue UI stabil ist.
|
||||
- d) Legacy-Admin vollständig deaktivieren (Routing/Login weg).
|
||||
5. **Integrationstests pro Funktion** (zwingend, da sonst Modul 8 erneut entsteht).
|
||||
|
||||
### 9.3 Reihenfolge-Empfehlung innerhalb C1
|
||||
|
||||
1. Fewo-Lodging-CRUD (niedriges Risiko, häufigste Aufgabe).
|
||||
2. Fewo-Preise + Saisons (mittel, zentral für Buchungen).
|
||||
3. Fewo-Reservierungen (hohes Risiko — muss mit Modul 8 zusammen gedacht werden).
|
||||
4. Fewo-Booking-Requests + Bilder.
|
||||
|
||||
**Aufwand:** ~8–10 Wochen abhängig von Funktionsumfang.
|
||||
**Abhängigkeiten:** Modul 1 (UI), Modul 2.8 (Queue) optional, eng mit Modul 8.
|
||||
|
||||
---
|
||||
|
||||
## 10. Modul 10 — Navigation & Page-Modell refactoren (Block C2)
|
||||
|
||||
**Briefing:**
|
||||
- „Im System, gerade in Fronten `sterntours.de/src/AppBundle/Listener/KernelControllerListener.php`, ist die Struktur der Navigation und der des Seitenbaums sehr eigenwillig und muss verbessert und optimiert werden. `mein.sterntours.de/app/Models/Page.php` — alles liegt in dieser Page-Tabelle und ist sehr schlecht wartbar und undurchschaubar. Hier muss ein deutlich cleaneres Konzept her."
|
||||
- `https://mein.sterntours.test/navigation-api` ist als Ansatz vorhanden, aber noch nicht gut.
|
||||
|
||||
### 10.1 Ist-Zustand (aus navigation.md / frontend-navigation/)
|
||||
|
||||
- Ein Symfony `KernelControllerListener` übernimmt Routing-Entscheidungen basierend auf `Page.realUrlPath` / Slug-Traversierung / hartkodierten Redirects / Template-Namen.
|
||||
- Alles liegt in **einer** Tabelle `page`, die sowohl Inhalte, Reiseprogramme, Fewo-Zuordnungen, Länder, News, Reiseführer referenziert.
|
||||
- Nested-Set-Logik (`lft`/`rgt`/`lvl`) ist unzuverlässig; derzeit wird sie im Listener bewusst umgangen.
|
||||
- Es existiert eine Backend-UI unter `/navigation-api`, aber sie ist read-only und deckt das Frontend-Mental-Modell nicht sauber ab.
|
||||
|
||||
### 10.2 Zielbild (Vorschlag)
|
||||
|
||||
Trennung in klar benannte Entitäten:
|
||||
|
||||
```
|
||||
pages — generische Inhaltsseiten (CMS-Content)
|
||||
menu_items — reine Navigationsknoten (Titel, Link, Sortierung, parent_id)
|
||||
routes/redirects — URL-Routing (Slug → Ziel-Entität) inkl. 301-Redirects
|
||||
content_types — Polymorphe Zuordnung: menu_item → [Page | TravelProgram | FewoLodging | Country | News | TravelGuide | ...]
|
||||
```
|
||||
|
||||
- `menu_items` ist der **einzige** Baum (adjacency list, evtl. mit `nested-set`-Paket gepflegt).
|
||||
- Jede `menu_item` hat einen `linkable_type` + `linkable_id` (Polymorphie) — so wird klar, was hinter einem Link steht, und das Model `Page` ist nicht mehr das Mega-Modell.
|
||||
- `routes`-Tabelle auflöst Slug → `linkable`, ersetzt `realUrlPath`-Suche.
|
||||
|
||||
### 10.3 Teilschritte
|
||||
|
||||
1. **Konzept-Doku** mit allen Alt-Datenfeldern in `page` und Zielzuordnung (welche bleiben auf `pages`, welche wandern auf `menu_items`, welche werden zu polymorphen Zielen).
|
||||
2. **Neue Tabellen aufbauen (migrativ):** `menu_items`, `routes`, optionale `redirects`.
|
||||
3. **Datenmigration:** `page` → `menu_items` + `pages` + `routes` (alles ableitbar; kein Datenverlust).
|
||||
4. **Laravel-Route-Driver bauen** (für Backend- und API-Lookups).
|
||||
5. **Symfony-Frontend anpassen:**
|
||||
- `KernelControllerListener` nutzt eine neue Lookup-API (`GET /api/routes/resolve?path=/foo/bar`) aus `mein.sterntours.de`.
|
||||
- Legacy-Queries auf `page`-Tabelle entfallen dort.
|
||||
6. **Neue Backend-UI** unter `/navigation` (nicht mehr `/navigation-api` read-only):
|
||||
- Drag&Drop-Tree.
|
||||
- Inline-Edit für Titel / Slug / Sichtbarkeit.
|
||||
- „Was ist hier verlinkt?" — direkter Sprung zum Content-Editor der Zielentität.
|
||||
- Live-Preview-Link ins Frontend.
|
||||
7. **Redirect-Verwaltung** als eigene UI (statt hartkodierter Liste im Listener).
|
||||
8. **Cache-Strategie** (Events: `RouteChanged` → Cache-Invalidierung; kein manuelles „Cache leeren"-Button mehr als primärer Weg).
|
||||
|
||||
**Aufwand:** ~6–8 Wochen.
|
||||
**Abhängigkeiten:** Modul 1 (UI-Pattern). Muss vor Modul 11 (CMS-Vereinheitlichung) stehen, damit CMS-Inhalte auf das neue Model angedockt werden.
|
||||
|
||||
---
|
||||
|
||||
## 11. Modul 11 — CMS-Vereinheitlichung (Block C3)
|
||||
|
||||
**Briefing:** Es existieren CMS-Ansätze unter `/cms/feedback`, `/cms/fewo/content`, `/cms/travel_guide/content`, `/iq/content/tree/index`, `/cms/news`, `/cms/answer_question`, `/cms/sidebar`, `/cms/content/infos`, `/cms/content/all`. „Die Einzelmodule können so bleiben. Es muss nur klarer in der Benutzerführung werden und auch aus der Hauptnavigation erreichbar sein und deutlich sein, wo was hingehört."
|
||||
|
||||
### 11.1 Teilschritte
|
||||
|
||||
1. **Inventar:** Jede dieser Routen inhaltlich beschreiben (Was wird gepflegt? Für welchen Frontend-Bereich?).
|
||||
2. **Dachstruktur:** Eine neue Haupt-Menü-Gruppe „Inhalte / CMS" im Seitenmenü mit Unterpunkten, die klar benannt sind (nicht mehr `/cms/content/infos` vs. `/cms/content/all`).
|
||||
3. **Landing-Page** `/content` als Dashboard mit Kacheln pro Bereich + Kurzbeschreibung („Was pflegst du hier?").
|
||||
4. **Konsistente Listen-/Edit-Views** (Baseline).
|
||||
5. **Verknüpfung mit Navigation (Modul 10):** Aus der Navigations-UI direkt in den passenden CMS-Bereich springen („Inhalt dieser Seite bearbeiten").
|
||||
6. **Berechtigungen vereinheitlichen:** Jedes CMS-Submodul hat eine eigene Permission (`cms.feedback`, `cms.news`, …); aktuell ist das teilweise inkonsistent.
|
||||
7. **Nicht ändern:** Die Einzellogik der Module (Datenmodelle bleiben). Nur UI / Dachnavigation / Permissions / Benennung.
|
||||
|
||||
**Aufwand:** ~3 Wochen.
|
||||
**Abhängigkeiten:** Modul 1, Modul 10 (für Verknüpfungen), Modul 3 (nur inhaltlich, keine Abhängigkeit in der Umsetzung).
|
||||
|
||||
---
|
||||
|
||||
## 12. Modul 12 — Migration Reiseverwaltung v2.stern-tours.de (Block C4, größtes Einzelmodul)
|
||||
|
||||
**Briefing:** „Jetzt kommt noch ein sehr großer Punkt: `v2.stern-tours.de/application/controllers/acp`. Hier werden Reisen angelegt und auch die Reisezeiträume verwaltet. Dieses ist mittlerweile absolut veraltet und auch fehleranfällig. Ein großes neues Modul wird es sein, dieses auf `mein.sterntours.de` zu migrieren — d. h. der komplette Funktionsumfang muss in das Backend einentwickelt und benutzerfreundlich gemacht werden, sowie müssen die gesamten Skripte etc. deutlich optimiert werden."
|
||||
|
||||
### 12.1 Umfang (grob aus Controller-Ordner)
|
||||
|
||||
```
|
||||
aegypten_api flight_period travel_country travel_insurance
|
||||
catalog keyword travel_departure_point travel_option
|
||||
feedback newsletter travel_destination travel_organizer
|
||||
travel_arrival_point page travel_discount travel_period
|
||||
travel_category travel_program travel_period_date travel_period_price
|
||||
travel_class travel_program_image travel_setting travel_general_notes
|
||||
wiki welcome
|
||||
```
|
||||
|
||||
Das sind ~25 Entitäten / Verwaltungsbereiche. Viele hängen miteinander zusammen (Program ↔ Period ↔ PeriodDate ↔ PeriodPrice).
|
||||
|
||||
### 12.2 Grundstrategie (Entscheidung Abschnitt 17.6)
|
||||
|
||||
- **Stück für Stück**, nicht Big Bang.
|
||||
- Pro Teil-Entität wird die **Datenstruktur überarbeitet und bereinigt** — d. h. sinnvolle Umbauten werden _mitgemacht_, nicht auf später verschoben (1:1-Portierung wäre zu teuer in Nachpflege).
|
||||
- Während der Migration läuft das Altsystem auf dem Live-Server **weiter** (Parallelbetrieb, Entscheidung Abschnitt 17.8). Jedes fertig migrierte Teil-Modul übernimmt _sofort_ die Hoheit (Schreibrechte), das Altsystem geht für diesen Bereich auf Read-only bzw. Redirect ins neue Backend.
|
||||
- **Teil-Migration auf Live** ist ausdrücklich vorgesehen: einzelne Entitäten dürfen produktiv auf das neue System umziehen, während andere noch in v2 bleiben. Voraussetzung: bidirektionale Datensicht (View oder Sync) für alle übergreifenden Reports.
|
||||
|
||||
### 12.3 Teilschritte (hohes Level — jedes Teil-Modul wird wie ein eigenes Mini-Projekt geführt)
|
||||
|
||||
1. **Inventar & Datenmodell-Review:** Pro Entität — Tabellen, Felder, Beziehungen, heute existierende Fehler / Sonderfälle.
|
||||
2. **Entscheidung pro Tabelle:** Welche Felder bleiben, welche werden umbenannt, welche Tabellen werden zusammengeführt, welche Indizes fehlen. Dokumentiert als kurzes Delta-Dokument pro Entität, bevor die Migration geschrieben wird.
|
||||
3. **Abhängigkeitsgraph** der Entitäten bauen → Migrationsreihenfolge ableiten.
|
||||
4. **Schrittweise Migration — empfohlene Reihenfolge:**
|
||||
- **Stammdaten zuerst** (wenige Abhängigkeiten, einfache CRUD): `travel_country`, `travel_class`, `travel_category`, `travel_organizer`, `travel_departure_point` (+ holiday), `travel_arrival_point`, `travel_destination`, `travel_general_notes`, `travel_insurance` + `travel_insurance_price`, `flight_period`, `travel_option`, `travel_discount`, `travel_setting`, `keyword`.
|
||||
- **Reiseprogramme:** `travel_program`, `travel_program_image`, `catalog`, `page`-Zuordnungen (hängt mit Modul 10 zusammen).
|
||||
- **Reisezeiträume:** `travel_period`, `travel_period_date`, `travel_period_price`, `travel_period_price_type` (eng verwoben, gemeinsam migrieren).
|
||||
- **Sonderfälle / Rand:** `aegypten_api`, `newsletter`, `feedback`, `wiki`, `welcome` (Dashboard).
|
||||
5. **Pro Teil-Modul:**
|
||||
- Datenstruktur als Laravel-Migration (mit Rückwärtskompatibilität).
|
||||
- Model + Repository + Service.
|
||||
- UI auf Baseline (Listen + Edit).
|
||||
- Integrationstests.
|
||||
- Dual-Write während Übergang, bis das Altsystem lesefrei / abgeschaltet ist.
|
||||
6. **Performance-/Query-Audit:** Laut Briefing „Skripte deutlich optimiert werden". Hier typische Ursachen angehen — N+1-Queries, fehlende Indizes, fehlende Eager Loads, zu viele JOINs in CI-Reports.
|
||||
7. **Preis-Engine:** Der kritischste Teil. Alle Preisberechnungen (Programm + Periode + Rabatte + Versicherung + Optionen) müssen in **einem** Service gekapselt werden. Dieser wird dann von:
|
||||
- Modul 6 (Angebote) — für `offer_items`,
|
||||
- Modul 4 (Direktbuchung) — für Buchungspreise,
|
||||
- Der Web-Buchungs-API,
|
||||
- Dem aktuell laufenden Altsystem (während Parallelbetrieb)
|
||||
genutzt. **Vermeidet eine Neuauflage des heutigen Preischaos.**
|
||||
8. **Frontend-Adapter:** `sterntours.de` muss zur Anzeige (Preise, Abfahrten) dieselben Daten bekommen. Entweder API-Call ins neue Laravel oder View-Layer in DB, bis das Frontend gegen die neue Quelle läuft.
|
||||
9. **Abschaltung v2:** Erst wenn alle Datenflüsse migriert sind, Legacy-Read-Zugriffe eliminiert und das Frontend gegen die neue Quelle läuft.
|
||||
|
||||
**Aufwand:** ~14–20 Wochen. Durch die Entscheidung „Stück für Stück mit Überarbeitung/Bereinigung" (Abschnitt 17.6) eher am oberen Ende, dafür am Ende kein technischer Schuldenberg.
|
||||
**Abhängigkeiten:** Modul 1 (UI), Modul 2 (Tooling/Tests), Modul 10 (Navigation, weil `travel_program` → `menu_items` verlinkt werden).
|
||||
|
||||
---
|
||||
|
||||
## 13. Modul 13 — Betrieb, Tests, Monitoring (Block D1)
|
||||
|
||||
Begleitendes Querschnittsmodul.
|
||||
|
||||
### 13.1 Teilschritte
|
||||
|
||||
1. **Test-Strategie:**
|
||||
- Pro neues Modul: Unit-Tests für Services, Feature-Tests für kritische Flows (Direktbuchung, Angebot → Buchung, Fewo-Belegung).
|
||||
- Contract-Tests für alle öffentlichen APIs (Booking-Import, Navigation-API).
|
||||
2. **CI-Pipeline** (Gitlab/GitHub Actions): Pint, Larastan, PHPUnit, optional Dusk für die wichtigsten Flows.
|
||||
3. **Queue-Monitoring:** Horizon (wenn Redis) oder Log-Channel + Alert bei Failed Jobs.
|
||||
4. **Error-Tracking:** Sentry / Flare / Bugsnag (eines davon auswählen).
|
||||
5. **Deployment-Pipeline:** Entweder Deployer/Envoyer-basiert oder Docker-Build; Maintenance-Mode-Script, Migrations- und Cache-Steps dokumentiert.
|
||||
6. **Rollback-Pläne** pro Migration (ist in Modul 3 bereits vorbildlich gemacht — Standard für alle neuen Module).
|
||||
|
||||
**Aufwand:** laufend, ca. 4 Wochen verteilt über das Gesamtprojekt.
|
||||
|
||||
---
|
||||
|
||||
## 14. Modul 14 — Framework- & Sprach-Upgrade (Block D2)
|
||||
|
||||
Nicht sofort, aber einplanen:
|
||||
|
||||
- **Laravel 10 → 11 Upgrade-Pfad:** separater Branch, nach Abschluss von Block A + Teilen B. Nicht während aktiver Alt-Migration (Modul 12).
|
||||
- **PHP 8.3** in Docker/CI festschreiben, aus `composer.json` die Obergrenze anheben.
|
||||
- **`jenssegers/date`** entfernen, überall Carbon.
|
||||
- Composer-Pakete konsolidieren (siehe `projekt-empfehlungen-2026-04.md` §2.3).
|
||||
|
||||
**Aufwand:** ~2 Wochen.
|
||||
|
||||
---
|
||||
|
||||
## 15. Modul 15 — Kundenportal / Kunden-Login (Zukunftsmodul, Block E)
|
||||
|
||||
**Briefing (Ergänzung aus den Antworten):** „Ein weiteres Modul für die Zukunft bitte mit aufnehmen: einen Kunden-Login, so dass die Kunden Zugang haben zu ihren eigenen Buchungen und Übersichten."
|
||||
|
||||
### 15.1 Ziel
|
||||
|
||||
Ein öffentlich erreichbarer, eingeschränkter Kunden-Bereich, in dem sich ein Kontakt mit seiner E-Mail-Adresse anmelden und seine eigenen Daten / Anfragen / Angebote / Buchungen einsehen kann. Klar abgegrenzt vom Mitarbeiter-Backend.
|
||||
|
||||
### 15.2 Datenmodell
|
||||
|
||||
- **Kein separates `users`-Model** für Kunden — Auth wird an `contacts` aufgehängt, Erweiterung:
|
||||
|
||||
```sql
|
||||
ALTER TABLE contacts
|
||||
ADD COLUMN password VARCHAR NULL,
|
||||
ADD COLUMN remember_token VARCHAR(100) NULL,
|
||||
ADD COLUMN email_verified_at DATETIME NULL,
|
||||
ADD COLUMN portal_enabled TINYINT(1) DEFAULT 0,
|
||||
ADD COLUMN last_login_at DATETIME NULL;
|
||||
|
||||
CREATE TABLE customer_sessions (
|
||||
id, contact_id, ip, user_agent, last_active_at, created_at, expires_at
|
||||
);
|
||||
```
|
||||
|
||||
- Laravel-Auth-Guard `customer` (eigener Guard neben dem Mitarbeiter-Guard `web`).
|
||||
|
||||
### 15.3 Teilschritte
|
||||
|
||||
1. **Registrierung / Erstanmeldung:**
|
||||
- Kunde erhält Einladung per Mail (Reiseunterlagen / Buchungsbestätigung enthalten einen „Zugang einrichten"-Link).
|
||||
- Alternativ: „Passwort setzen" via E-Mail-Verifizierung ausgehend von einer bestehenden Mail-Adresse im `contacts`-Datensatz.
|
||||
- Kein Self-Signup ohne bestehenden Kontakt — Datenbereinigung würde sonst erneut leiden.
|
||||
2. **Login / Passwort-Reset / 2FA optional.**
|
||||
3. **Portal-Views (auf `sterntours.de` oder Subdomain `mein.sterntours.de/portal` — Architekturentscheidung noch offen):**
|
||||
- Dashboard („Ihre nächste Reise in X Tagen").
|
||||
- Meine Anfragen.
|
||||
- **Meine Angebote** (Integration mit Modul 6: eingeloggter Kunde sieht dort dieselben Dokumente wie über den Token-Link, zusätzlich historische Angebote).
|
||||
- Meine Buchungen (Dokumente, Zahlungsstand, Reiseunterlagen).
|
||||
- Meine Daten (Adresse, Einwilligungen, Newsletter-Status).
|
||||
4. **Datenschutz / DSGVO:**
|
||||
- Nur eigene Daten sichtbar (strenge Scope-Checks in Controllern + Policies).
|
||||
- Download der eigenen Daten als Export (Art. 15 DSGVO).
|
||||
- Lösch-Anfrage-Flow (verknüpft mit dem `deleted_at`-Flow aus Modul 3).
|
||||
5. **Integration mit Token-Links (Modul 6):**
|
||||
- Ein Token-Link funktioniert weiterhin ohne Login.
|
||||
- Ist der Kunde eingeloggt und klickt den Link, wird das Angebot in das Portal-Layout eingebettet (nicht doppelte Darstellung).
|
||||
6. **Permissions & Sicherheit:**
|
||||
- Separate Passport-Scopes für Portal-API.
|
||||
- Rate-Limiting auf Login-Endpunkte.
|
||||
- Audit-Log (wer hat wann was eingesehen) — wichtig bei Angebotsannahmen im Portal.
|
||||
7. **UI:** Kein Teil der Mitarbeiter-UI-Baseline (andere Zielgruppe). Eigenes, leichtes Layout (Bootstrap 5), das zum `sterntours.de`-Look passt.
|
||||
|
||||
### 15.4 Abhängigkeiten
|
||||
|
||||
- Modul 3 Phase 2 (`contacts`-Tabelle) ist **zwingende** Voraussetzung — sonst liegen die Kundenlogins auf der alten `customer`-Tabelle.
|
||||
- Modul 6 (Angebote) sollte mindestens Grundstand haben, damit Portal-Angebotsansicht sinnvoll Inhalt bekommt.
|
||||
- Profitiert stark von Modul 2.8 (Queue) für Einladungs-/Benachrichtigungsmails.
|
||||
|
||||
### 15.5 Einordnung in die Roadmap
|
||||
|
||||
- **Nicht Teil der ersten Welle.** Frühester sinnvoller Start: nach Abschluss von Modul 6 + Modul 3 Phase 4.
|
||||
- Entspricht einem eigenen Block „E — Kundenseitige Self-Service-Funktionen" und kann zeitlich mit Modul 14 (Framework-Upgrade) parallel laufen, wenn Kapazität da ist.
|
||||
|
||||
**Aufwand:** ~6–8 Wochen für MVP (Login + Angebote + Buchungen + Daten), plus 2–3 Wochen für DSGVO-/Self-Service-Funktionen.
|
||||
|
||||
---
|
||||
|
||||
## 16. Zusammenfassung — empfohlene Reihenfolge und Parallelisierung
|
||||
|
||||
```
|
||||
Monat 1–2 A1 UI-Baseline + A2 Technik-Hausaufgaben (+ Start A3 Phase 2)
|
||||
+ Frontend-Migration Vite/dart-sass/Bootstrap 5 (Modul 1.4) parallel
|
||||
Monat 2–3 A3 Customer/Lead/Booking Phasen 2+3 abschließen
|
||||
Monat 3 A3 Phase 4 + B4 Fewo-Doppelbuchung (Turnustag-Logik) parallel
|
||||
Monat 4 B1 Direktbuchung (mit Mail-Toggle) + B2 Organisation-Tab
|
||||
Monat 4–5 B3 E-Mail-Auto-Save (nach A3 P4)
|
||||
Monat 5–7 B5 Angebots-Modul (inkl. Versionierung + Freigabe-Link)
|
||||
Monat 6–9 C1 Admin-Migration sterntours.de (zügig, kein harter Cut)
|
||||
Monat 7–9 C2 Navigation + C3 CMS-Vereinheitlichung
|
||||
Monat 8–16 C4 v2.stern-tours.de Reisenverwaltung — Stück für Stück mit
|
||||
Teil-Migration auf Live und Parallelbetrieb
|
||||
Monat ≥10 E1 Kundenportal / Kunden-Login (frühestens nach B5 + A3 P4)
|
||||
Laufend D1 Betrieb/Tests, D2 Framework-Upgrade gegen Ende
|
||||
```
|
||||
|
||||
Einige Module können parallelisiert werden, wenn Kapazität vorhanden ist. Kritischer Pfad: **A1 → A3 → B5 → C4**. Modul 15 (Portal) liegt off-path und kann flexibel eingeordnet werden.
|
||||
|
||||
---
|
||||
|
||||
## 17. Entscheidungen (geklärt)
|
||||
|
||||
Die acht zuvor offenen Fragen wurden vom Auftraggeber beantwortet. Die Entscheidungen sind in den jeweiligen Modulen bereits eingearbeitet; hier die konsolidierte Referenz:
|
||||
|
||||
| # | Thema | Entscheidung | Fundstelle im Plan |
|
||||
|---|-------|-------------|--------------------|
|
||||
| 17.1 | **Angebots-Versionen** | Jede Änderung nach dem Versand erzeugt eine neue Version (V2, V3 …). Speicheroptimierung durch spätere Archivierung/Löschung alter Versionen. | Modul 6.2 (`offer_versions`), 6.3.6 (Versionierung), 6.4 (Archivstrategie) |
|
||||
| 17.2 | **Angebots-Annahme** | Beides: Admin-seitige Statusänderung **und** kundenseitiger Freigabe-Link (Token-URL) mit Bestätigungsseite. | Modul 6.3.8 (`offer_access_tokens`, `/angebot/{token}`) |
|
||||
| 17.2b | **Kundenportal** | Neues Zukunftsmodul (Modul 15): Kunden-Login mit Zugang zu eigenen Buchungen/Angeboten/Daten. | Modul 15 |
|
||||
| 17.3 | **Angebots-Dokumente** | Dual-Modell analog zur heutigen Buchungs-Logik in `app/Http/Controllers/BookingController.php`: zentral hinterlegte Dokumente + freie Uploads pro Angebot. | Modul 6.3.4 |
|
||||
| 17.4 | **Direktbuchung im Backend** | Gesonderter Einstieg mit Auswahl im letzten Schritt: „Anlegen & Bestätigung senden" **oder** „Nur anlegen (keine Mail)". Nachversand bleibt jederzeit manuell möglich. | Modul 4.2.6 |
|
||||
| 17.5 | **Fewo-Überlappungen** | Kein harter DB-Constraint. Überlappungen werden im `FewoReservationService` nach klaren Turnustag-Regeln geprüft (An-/Abreise am selben Tag legitim). | Modul 8.2.5 |
|
||||
| 17.6 | **v2-Migrationsstrategie** | Stück für Stück mit Überarbeitung und Bereinigung (keine 1:1-Portierung). | Modul 12.2 |
|
||||
| 17.7 | **Frontend-Tooling** | Vite + dart-sass + schrittweise Bootstrap 5. Freigegeben. | Modul 1.4 |
|
||||
| 17.8 | **Abschaltplan Altsysteme** | Parallelbetrieb, bis Migration abgeschlossen. `sterntours.de/AdminController` zügig migrieren (Modul 9). `v2.stern-tours.de` mit Teil-Migrationen auf dem Live-Server, damit der Betrieb ununterbrochen weiterläuft. | Modul 12.2 |
|
||||
|
||||
### Direkt daraus folgende neue Bausteine
|
||||
|
||||
- **Neue Tabellen:** `offer_versions`, `offer_access_tokens` (Modul 6).
|
||||
- **Neue Routen (öffentlich):** `/angebot/{token}`, `/angebot/{token}/accept`, `/angebot/{token}/decline` auf `sterntours.de` bzw. Subdomain.
|
||||
- **Neues Modul 15:** Kundenportal / Kunden-Login (eigener Block E, Zukunftsmodul).
|
||||
- **Neue Service-Klasse:** `FewoReservationService` mit Turnustag-Logik (Modul 8).
|
||||
- **Neue UI-Komponente:** Belegungskalender mit Wechseltag-Markierung (Modul 8.2.7).
|
||||
- **Direktbuchungs-Flag:** `silent_created` auf `bookings` (Modul 4).
|
||||
|
||||
---
|
||||
|
||||
## 18. Nächste konkrete Schritte (Start der Entwicklung)
|
||||
|
||||
Nach dieser Abstimmung kann die Entwicklung starten. Sinnvolle erste Arbeitspakete, jeweils als eigene PRs / Tickets:
|
||||
|
||||
1. **A2 Quick Wins** (sofort, blockiert nichts):
|
||||
- Composer-Wildcards pinnen.
|
||||
- `navigation/cache/clear` absichern.
|
||||
- `booking/import` auf POST-only.
|
||||
- Pint + Larastan + CI aufsetzen.
|
||||
2. **A3 Phase 1 live deployen** (Customer-Deduplizierung — steht auf Test, braucht Backup + CSV-Review).
|
||||
3. **A1 UI-Baseline dokumentieren** (Inventar + Baseline-Spec, ohne bereits umzubauen).
|
||||
4. **Modul 1.4 Frontend-Migration** als eigener Feature-Branch starten (Vite/dart-sass/Bootstrap 5).
|
||||
5. **A3 Phase 2 vorbereiten** (Code-Anpassungen `Customer::$table`, `Lead::$table`, Repositories auf `inquiry_id`).
|
||||
|
||||
Schritt 1–3 können parallel angegangen werden; Schritt 4 läuft im Hintergrund, bis der Pilot-View auf der neuen Pipeline läuft. Sobald A3 Phase 2 produktiv ist, ist der Weg frei für Modul 4 (Direktbuchung) und anschließend Modul 6 (Angebote).
|
||||
942
dev/offers/umsetzung.md
Normal file
942
dev/offers/umsetzung.md
Normal file
|
|
@ -0,0 +1,942 @@
|
|||
# Modul 6 — Angebote: Implementierungs-Tickets
|
||||
|
||||
**Status:** Entwurf, bereit zur Umsetzung
|
||||
**Erstellt:** April 2026
|
||||
**Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md)
|
||||
**Abhängigkeiten-Check vor Start:**
|
||||
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren.
|
||||
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`.
|
||||
- Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen.
|
||||
- Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## 0. Übersicht
|
||||
|
||||
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **7–9 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
|
||||
|
||||
| Phase | Inhalt | Tickets | Aufwand |
|
||||
|-------|--------|---------|---------|
|
||||
| A | Fundament (DB / Models / Repositories / Services / Routing / Permissions) | A1–A7 | ~10 Tage |
|
||||
| B | Admin-UI (Mitarbeiter-Backend) | B1–B9 | ~18 Tage |
|
||||
| C | Vorlagen (Offer-Templates) | C1–C3 | ~3 Tage |
|
||||
| D | Kundenseitiger Freigabe-Link (öffentliches Frontend) | D1–D5 | ~5 Tage |
|
||||
| E | Versionierung & Archivierung | E1–E3 | ~3 Tage |
|
||||
| F | Polish, Automatik, Tests | F1–F6 | ~5 Tage |
|
||||
|
||||
Nach Abschluss von Phase B5 kann D parallel zu C + E gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Reihenfolge und kritischer Pfad
|
||||
|
||||
```
|
||||
A1 → A2 → A3 → A4 → A5 → A6 → A7 ─────────┐
|
||||
│
|
||||
┌─── B1 → B2 → B3 → B4 → B5 ──────┼──► B6 → B7 ─┐
|
||||
│ │ │
|
||||
│ ┌── C1 → C2 ─┤ ├─► B8 → B9
|
||||
│ │ │ │
|
||||
│ ├── D1 → D2 → D3 → D4 → D5─┤
|
||||
│ │ │
|
||||
│ └── E1 → E2 → E3 ──────────┘
|
||||
│
|
||||
└──► F1 … F6 (laufend)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Fundament
|
||||
|
||||
### Ticket A1 — Datenbank-Migrationen anlegen
|
||||
|
||||
**Typ:** Migration
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** Modul 3 Phase 2 (`contacts`, `inquiries`)
|
||||
|
||||
**Dateien (neu):**
|
||||
- `database/migrations/2026_05_01_100001_create_offers_table.php`
|
||||
- `database/migrations/2026_05_01_100002_create_offer_versions_table.php`
|
||||
- `database/migrations/2026_05_01_100003_create_offer_items_table.php`
|
||||
- `database/migrations/2026_05_01_100004_create_offer_templates_table.php`
|
||||
- `database/migrations/2026_05_01_100005_create_offer_files_table.php`
|
||||
- `database/migrations/2026_05_01_100006_create_offer_access_tokens_table.php`
|
||||
- `database/migrations/2026_05_01_100007_add_offer_id_to_bookings_table.php`
|
||||
|
||||
**Schema `offers`:**
|
||||
```php
|
||||
Schema::create('offers', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('offer_number', 32)->unique(); // z. B. "2026-00123"
|
||||
$t->foreignId('contact_id')->constrained('contacts')->restrictOnDelete();
|
||||
$t->foreignId('inquiry_id')->nullable()->constrained('inquiries')->nullOnDelete();
|
||||
$t->foreignId('booking_id')->nullable()->constrained('bookings')->nullOnDelete();
|
||||
$t->enum('status', ['draft', 'sent', 'accepted', 'declined', 'expired', 'withdrawn'])
|
||||
->default('draft');
|
||||
$t->unsignedBigInteger('current_version_id')->nullable(); // FK wird nachträglich gesetzt
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['status', 'contact_id']);
|
||||
$t->index('inquiry_id');
|
||||
});
|
||||
```
|
||||
|
||||
**Schema `offer_versions`:**
|
||||
```php
|
||||
Schema::create('offer_versions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('offer_id')->constrained('offers')->cascadeOnDelete();
|
||||
$t->unsignedInteger('version_no');
|
||||
$t->enum('status', ['draft', 'sent', 'accepted', 'declined', 'expired', 'superseded'])
|
||||
->default('draft');
|
||||
$t->date('valid_until')->nullable();
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
$t->string('headline')->nullable();
|
||||
$t->text('intro_text')->nullable();
|
||||
$t->longText('itinerary_text')->nullable();
|
||||
$t->text('closing_text')->nullable();
|
||||
$t->foreignId('template_id')->nullable()->constrained('offer_templates')->nullOnDelete();
|
||||
$t->string('pdf_path')->nullable();
|
||||
$t->boolean('pdf_archived')->default(false);
|
||||
$t->dateTime('sent_at')->nullable();
|
||||
$t->dateTime('accepted_at')->nullable();
|
||||
$t->enum('accepted_via', ['customer_link', 'admin', 'email'])->nullable();
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->timestamps();
|
||||
|
||||
$t->unique(['offer_id', 'version_no']);
|
||||
});
|
||||
```
|
||||
|
||||
Danach nachträglicher FK auf `offers.current_version_id → offer_versions.id`.
|
||||
|
||||
**Schema `offer_items`:**
|
||||
```php
|
||||
Schema::create('offer_items', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete();
|
||||
$t->unsignedInteger('position')->default(0);
|
||||
$t->enum('type', ['travel','service','option','discount','insurance','custom']);
|
||||
$t->string('title');
|
||||
$t->text('description')->nullable();
|
||||
$t->unsignedInteger('quantity')->default(1);
|
||||
$t->decimal('price_per_unit', 10, 2)->default(0);
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
$t->foreignId('travel_program_id')->nullable(); // kein FK solange v2 nicht migriert
|
||||
$t->foreignId('fewo_lodging_id')->nullable();
|
||||
$t->json('metadata')->nullable();
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_version_id', 'position']);
|
||||
});
|
||||
```
|
||||
|
||||
**Schema `offer_templates`:**
|
||||
```php
|
||||
Schema::create('offer_templates', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('name');
|
||||
$t->string('description')->nullable();
|
||||
$t->string('organization')->nullable(); // "Reise-Organisation", aus Briefing
|
||||
$t->string('headline')->nullable();
|
||||
$t->text('intro_text')->nullable();
|
||||
$t->longText('itinerary_text')->nullable();
|
||||
$t->text('closing_text')->nullable();
|
||||
$t->json('items_json')->nullable(); // serialisierte Default-Positionen
|
||||
$t->json('default_document_ids')->nullable(); // IDs der zentral hinterlegten Dokumente
|
||||
$t->boolean('active')->default(true);
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
});
|
||||
```
|
||||
|
||||
**Schema `offer_files`** (bewusst analog zu `booking_files`, später Migration in `attachments`):
|
||||
```php
|
||||
Schema::create('offer_files', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete();
|
||||
$t->string('identifier')->nullable(); // z. B. "letterhead" für feste Typen
|
||||
$t->string('disk')->default('offers');
|
||||
$t->string('dir');
|
||||
$t->string('filename');
|
||||
$t->string('original_name');
|
||||
$t->string('ext', 10);
|
||||
$t->string('mine', 100); // Schreibweise übernommen aus BookingFile
|
||||
$t->unsignedBigInteger('size');
|
||||
$t->boolean('frozen')->default(false); // true ab Versand, um Anhang „einzufrieren"
|
||||
$t->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
**Schema `offer_access_tokens`:**
|
||||
```php
|
||||
Schema::create('offer_access_tokens', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete();
|
||||
$t->string('token', 64)->unique();
|
||||
$t->dateTime('expires_at')->nullable();
|
||||
$t->dateTime('opened_at')->nullable();
|
||||
$t->dateTime('accepted_at')->nullable();
|
||||
$t->dateTime('declined_at')->nullable();
|
||||
$t->string('ip_address', 45)->nullable();
|
||||
$t->string('user_agent', 255)->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
**`add_offer_id_to_bookings_table`:** Spalte `bookings.offer_id` als nullable FK (Rückverweis bei Konvertierung).
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Alle 7 Migrationen laufen sauber durch (`artisan migrate`) und `migrate:rollback` invertiert sie.
|
||||
- [ ] FKs sind konsistent; kein Drop einer Tabelle bricht andere.
|
||||
- [ ] Mit `migrate:fresh --seed` reproduzierbar.
|
||||
|
||||
**Risiken:**
|
||||
- `travel_program_id` / `fewo_lodging_id` haben aktuell keine Laravel-Tabelle (v2). FK vorerst nicht setzen, Werte nur als Referenz speichern. Ticket beim v2-Modul: FK nachreichen.
|
||||
|
||||
---
|
||||
|
||||
### Ticket A2 — Eloquent-Models
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** A1
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Models/Offer.php`
|
||||
- `app/Models/OfferVersion.php`
|
||||
- `app/Models/OfferItem.php`
|
||||
- `app/Models/OfferTemplate.php`
|
||||
- `app/Models/OfferFile.php`
|
||||
- `app/Models/OfferAccessToken.php`
|
||||
|
||||
**`Offer.php`:**
|
||||
- `$fillable`, `$casts` (status als Enum, Timestamps).
|
||||
- Relationen: `contact()`, `inquiry()`, `booking()`, `versions()`, `currentVersion()`, `createdBy()`.
|
||||
- Scopes: `scopeOpen()`, `scopeForContact($q, $id)`, `scopeWithStatus($q, array $stati)`.
|
||||
- Factories: `HasFactory`.
|
||||
|
||||
**`OfferVersion.php`:**
|
||||
- Relationen: `offer()`, `items()`, `files()`, `tokens()`, `template()`, `createdBy()`.
|
||||
- Helper: `isEditable()` (true wenn status=draft), `latestToken()`, `totalPriceFormatted()`.
|
||||
- Dateipfad-Helper: `getPdfUrl()` analog zu `BookingFile::getURL()`.
|
||||
|
||||
**`OfferFile.php`:**
|
||||
- Gleiche Methoden wie `BookingFile::getURL()`, `::getIconExt()`, `::formatBytes()` — Interface-kompatibel, damit derselbe Frontend-Code verwendbar bleibt.
|
||||
|
||||
**`OfferAccessToken.php`:**
|
||||
- `::generate(OfferVersion $v, ?Carbon $expiresAt = null): self` — erzeugt kryptografisch sicheren Token (`Str::random(64)`), speichert, gibt zurück.
|
||||
- `scopeActive()` — nicht expired, nicht declined.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] `Offer::factory()->create()` funktioniert.
|
||||
- [ ] `$offer->currentVersion->items` liefert korrekte Collection.
|
||||
- [ ] `OfferAccessToken::generate()` erzeugt unique Token, Kollisionsschutz per `unique`-Constraint.
|
||||
|
||||
---
|
||||
|
||||
### Ticket A3 — Repositories
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** A2
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Repositories/OfferRepository.php` extends `BaseRepository`
|
||||
- `app/Repositories/OfferVersionRepository.php` extends `BaseRepository`
|
||||
- `app/Repositories/OfferFileRepository.php` extends `FileRepository` (analog `BookingFileRepository`)
|
||||
- `app/Repositories/OfferTemplateRepository.php` extends `BaseRepository`
|
||||
|
||||
**`OfferRepository`:**
|
||||
- `create(array $data, int $contactId, ?int $inquiryId = null): Offer` — legt `Offer` + `OfferVersion#1` (status=draft) an, setzt `current_version_id`.
|
||||
- `generateOfferNumber(): string` — Format `{YYYY}-{5-stellig laufend}`, nutzt `DB::transaction` mit `SELECT … FOR UPDATE` auf einer Zähler-Tabelle ODER nimmt `MAX(offer_number)` mit Transaktion.
|
||||
- `findByNumber(string $no): ?Offer`
|
||||
|
||||
**`OfferVersionRepository`:**
|
||||
- `updateDraft(OfferVersion $v, array $data): OfferVersion` — nur wenn `status=draft`; Items werden aus `$data['items']` synchronisiert.
|
||||
- `createNewVersion(Offer $offer): OfferVersion` — dupliziert aktuelle Version inkl. items + files (als neue Zeilen), `version_no = max+1`, vorherige wird `superseded`, `offer.current_version_id` wird aktualisiert. Atomar in Transaktion.
|
||||
- `markSent(OfferVersion $v): OfferVersion` — setzt status, `sent_at`, `frozen` auf allen `offer_files`.
|
||||
|
||||
**`OfferFileRepository` (analog `BookingFileRepository`):**
|
||||
- `__construct(OfferFile $model)`
|
||||
- `save()` schreibt mit `$this->offer_version_id`, `$this->identifier`, `$this->dir = /files/YYYY/MM/`.
|
||||
- `response()` identische JSON-Struktur wie `BookingFileRepository::response()`, damit Frontend-Code geteilt werden kann.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] `OfferRepository::create()` erzeugt Offer + initiale Version atomar; Rollback bei Fehler.
|
||||
- [ ] `generateOfferNumber()` ist race-safe (parallele Tests erzeugen keine Duplikate).
|
||||
- [ ] `createNewVersion()` kopiert Items + Files, Preise bleiben gleich, alte Version `superseded`.
|
||||
|
||||
---
|
||||
|
||||
### Ticket A4 — OfferService (Business-Logik)
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** A3
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Services/OfferService.php`
|
||||
|
||||
**Methoden:**
|
||||
```php
|
||||
public function createFromInquiry(int $inquiryId, ?int $templateId = null): Offer;
|
||||
public function createBlank(int $contactId, ?int $templateId = null): Offer;
|
||||
public function applyTemplate(OfferVersion $v, OfferTemplate $template): OfferVersion;
|
||||
public function updateVersion(OfferVersion $v, array $data): OfferVersion;
|
||||
public function send(OfferVersion $v, array $mailData): void; // erzeugt Token, versendet Mail, wechselt Status
|
||||
public function markAccepted(OfferVersion $v, string $via, ?string $ip = null, ?string $ua = null): void;
|
||||
public function markDeclined(OfferVersion $v, string $via, ?string $reason = null): void;
|
||||
public function supersede(OfferVersion $v): OfferVersion; // erzeugt neue Version
|
||||
public function withdraw(Offer $offer, string $reason): void;
|
||||
public function convertToBooking(Offer $offer): Booking; // delegiert an BookingService::createManual()
|
||||
```
|
||||
|
||||
**Logikregeln:**
|
||||
- `send()` nur möglich, wenn Version `status=draft` und `current_version` der Offer ist.
|
||||
- `markAccepted()` nur möglich bei `status=sent`; Offer.status wechselt ebenfalls auf `accepted`; ältere noch aktive Versionen werden `superseded`.
|
||||
- `supersede()` deaktiviert bestehenden Token, erzeugt aber beim nächsten `send()` einen neuen.
|
||||
- `convertToBooking()` nur bei Offer.status=accepted; nutzt `BookingService::createManual($offer->currentVersion->toBookingData())`; setzt `offer.booking_id`, kopiert `offer_files` als `booking_files`.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Unit-Tests für jede öffentliche Methode (happy path + Statusguard-Fehler).
|
||||
- [ ] `convertToBooking()` arbeitet idempotent — zweiter Aufruf wirft DomainException.
|
||||
- [ ] Service wirft sprechende, eigene Exceptions (`OfferNotEditableException`, `InvalidOfferStatusException`).
|
||||
|
||||
---
|
||||
|
||||
### Ticket A5 — FormRequests
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** A3
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Http/Requests/Offer/StoreOfferRequest.php`
|
||||
- `app/Http/Requests/Offer/UpdateVersionRequest.php`
|
||||
- `app/Http/Requests/Offer/SendOfferRequest.php`
|
||||
- `app/Http/Requests/Offer/OfferTemplateRequest.php`
|
||||
|
||||
Validierungen pro Feld (Preise ≥ 0, `valid_until` in Zukunft, HTML-Sanitizing für `itinerary_text`, Dateiuploads MIME-Typen wie bei `BookingFile`).
|
||||
|
||||
---
|
||||
|
||||
### Ticket A6 — Routing + Permissions
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** A4
|
||||
|
||||
**Dateien (geändert):**
|
||||
- `routes/web.php` — neuer Block analog zu `bookings`/`leads`:
|
||||
|
||||
```php
|
||||
Route::group(['middleware' => ['auth.2fa']], function () {
|
||||
Route::get('data/table/offers', 'OfferController@getOffers')->name('data_table_offers');
|
||||
Route::get('/offers/{step?}', 'OfferController@index')->name('offers');
|
||||
Route::get('/offer/detail/{id}', 'OfferController@detail')->name('offer_detail');
|
||||
Route::post('/offer/detail/{id}', 'OfferController@store')->name('offer_detail_store');
|
||||
Route::post('/offer/modal/load', 'OfferController@loadModal')->name('offer_modal_load');
|
||||
Route::get('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action');
|
||||
Route::post('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action_store');
|
||||
Route::get('/offer/delete/{id}/{del?}', 'OfferController@delete')->name('offer_delete');
|
||||
Route::get('/offer/pdf/{versionId}', 'OfferController@pdf')->name('offer_pdf');
|
||||
|
||||
// Templates
|
||||
Route::get('/offer-templates', 'OfferTemplateController@index')->name('offer_templates');
|
||||
Route::get('/offer-template/detail/{id}', 'OfferTemplateController@detail')->name('offer_template_detail');
|
||||
Route::post('/offer-template/detail/{id?}', 'OfferTemplateController@store')->name('offer_template_detail_store');
|
||||
Route::get('/offer-template/delete/{id}', 'OfferTemplateController@delete')->name('offer_template_delete');
|
||||
|
||||
// Files (analog zu BookingController action-upload-booking-file)
|
||||
Route::post('/offer/upload/{versionId}', 'OfferFileController@upload')->name('offer_file_upload');
|
||||
Route::get('/offer/file/delete/{id}', 'OfferFileController@delete')->name('offer_file_delete');
|
||||
});
|
||||
```
|
||||
|
||||
**Permissions** (in Seeder `database/seeds/PermissionSeeder.php` oder via Config):
|
||||
- `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w`
|
||||
|
||||
**Menü-Eintrag** in `resources/views/layouts/_sidenav.blade.php` (analog zu bestehendem „Anfragen" und „Buchungen").
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Routen erreichbar nur mit Permission.
|
||||
- [ ] Menüpunkt „Angebote" erscheint zwischen „Anfragen" und „Buchungen".
|
||||
|
||||
---
|
||||
|
||||
### Ticket A7 — Factories + Seeder (für Tests)
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** A2
|
||||
|
||||
**Dateien (neu):**
|
||||
- `database/factories/OfferFactory.php`
|
||||
- `database/factories/OfferVersionFactory.php`
|
||||
- `database/factories/OfferItemFactory.php`
|
||||
- `database/factories/OfferTemplateFactory.php`
|
||||
|
||||
Factories decken alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Admin-UI
|
||||
|
||||
### Ticket B1 — Offer-Liste `/offers`
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** A6
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Http/Controllers/OfferController.php` — Methode `index()`, `getOffers()`.
|
||||
- `resources/views/offer/index.blade.php`
|
||||
- `resources/views/offer/_list_row.blade.php` (optional, wenn DataTable serverseitig gerendert)
|
||||
|
||||
**Ablauf:**
|
||||
- DataTable (Server-side via `DataTableController`-Pattern, siehe `data/table/bookings`-Route).
|
||||
- Spalten: Angebots-Nr. | Kontakt | Reise/Organisation | Version | Status-Badge | Gültig bis | Gesamtpreis | Mitarbeiter | Erstellt am | Aktionen.
|
||||
- Filter: Status (Multi-Select), Mitarbeiter, Datumsrange (erstellt/gültig bis), Volltext (Kontakt/Nummer).
|
||||
- Status-Badges (wiederverwendbar via `<x-ui.status-badge>`):
|
||||
- `draft` grau, `sent` blau, `accepted` grün, `declined` rot, `expired` gelb, `withdrawn` schwarz.
|
||||
- Aktionen pro Zeile: Öffnen, PDF, Senden, Duplizieren als neues Angebot, Löschen.
|
||||
- Toolbar: Button „Neues Angebot" (→ öffnet Modal aus Ticket B2).
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Pagination, Sortierung, Filter wie in `/bookings`.
|
||||
- [ ] Performance: bei 10.000 Angeboten Ladezeit <1s (Index-Check).
|
||||
|
||||
---
|
||||
|
||||
### Ticket B2 — „Neues Angebot" Modal + Wizard-Einstieg
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 1.5 Tage
|
||||
**Abhängigkeiten:** B1
|
||||
|
||||
**Dateien (neu):**
|
||||
- `resources/views/offer/modal-new-offer.blade.php`
|
||||
- `resources/views/offer/create.blade.php` (leiterer Wizard-Einstieg)
|
||||
|
||||
**UI-Fluss:**
|
||||
1. Modal öffnet aus zwei Kontexten:
|
||||
- Global: `/offers` → Toolbar „Neues Angebot".
|
||||
- Aus Anfrage: `/inquiry/detail/{id}` → Button „Angebot erstellen" (neu) — pre-fillt Kontakt + Anfrage.
|
||||
2. Modal-Felder: Kontakt (Autocomplete `/data/table/contacts`), optional Anfrage (Autocomplete Inquiries des Kontakts), Vorlage (Dropdown `offer_templates`).
|
||||
3. Bei Submit: `POST /offer/action/create` → legt via `OfferService::createFromInquiry()` oder `::createBlank()` an, Redirect auf `/offer/detail/{id}`.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Kontakt-Autocomplete greift auf `ContactController::getContacts`.
|
||||
- [ ] Vorlage optional (leere Auswahl = leeres Angebot).
|
||||
- [ ] Nach Submit landet der User direkt im Editor der neuen Version.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B3 — Offer-Editor (Detail + Versionsansicht)
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 5 Tage (größtes UI-Ticket)
|
||||
**Abhängigkeiten:** B2
|
||||
|
||||
**Dateien (neu):**
|
||||
- `resources/views/offer/detail.blade.php` (Haupt-View, analog zu `booking/detail.blade.php`)
|
||||
- `resources/views/offer/_detail_header.blade.php` — Status-Leiste, Buttons (Versenden, Kopie als neue Version, In Buchung umwandeln, Löschen)
|
||||
- `resources/views/offer/_detail_contact.blade.php` — Kontakt-Card (read-only, Link zu `/contact/detail/{id}`)
|
||||
- `resources/views/offer/_detail_content.blade.php` — Headline + Intro + Itinerary (WYSIWYG) + Closing
|
||||
- `resources/views/offer/_detail_items.blade.php` — Positions-Liste (Drag&Drop via SortableJS)
|
||||
- `resources/views/offer/_detail_items_row.blade.php` — einzelne Position
|
||||
- `resources/views/offer/_detail_versions.blade.php` — History-Tab: V1, V2 … mit PDF-Download je Version
|
||||
|
||||
**Layout (Inspiriert an `booking/detail`):**
|
||||
```
|
||||
┌─ Header: "Angebot 2026-00123 · V2 · Status: GESENDET" ──┐
|
||||
│ [PDF] [Erneut senden] [Neue Version] [In Buchung] [...] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─ Linke Spalte (Stammdaten) ──┬─ Rechte Spalte (Tabs) ──┐
|
||||
│ • Kontakt │ [Inhalt][Positionen] │
|
||||
│ • Anfrage (verknüpft) │ [Dokumente][Versionen] │
|
||||
│ • Gültig bis │ [E-Mails][Log] │
|
||||
│ • Mitarbeiter │ │
|
||||
│ • Summe │ (aktiver Tab-Inhalt) │
|
||||
└──────────────────────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
**Inhalts-Tab:**
|
||||
- Felder `headline`, `intro_text`, `itinerary_text` (TinyMCE oder TipTap — gleicher WYSIWYG wie CMS-Modul), `closing_text`.
|
||||
- Inline-Edit mit Auto-Save (Debounce 3s, `POST /offer/detail/{id}` mit Action-Flag `autosave-content`).
|
||||
|
||||
**Positionen-Tab:**
|
||||
- Drag&Drop-Sortierung (SortableJS).
|
||||
- Pro Zeile: Typ-Icon, Titel, Menge, Einzelpreis, Gesamtpreis, Löschen, „Aus Reiseprogramm füllen" (Dropdown aus v2-Programmen, solange v2 läuft: über Lesemodell).
|
||||
- Summe live unten.
|
||||
- Button „Position hinzufügen" öffnet kleines Modal (Typ wählen).
|
||||
|
||||
**Bearbeitbarkeitsregel:**
|
||||
- Wenn `OfferVersion::isEditable()` = false → alle Felder read-only, Hinweis „Diese Version ist versendet. Erstelle eine neue Version, um Änderungen vorzunehmen." mit Button „Neue Version".
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Autosave für Content + Items (kein manuelles Speichern nötig).
|
||||
- [ ] Positionen lassen sich drag&drop verschieben, `position` wird gespeichert.
|
||||
- [ ] Summe stimmt mit `total_price` in DB überein (Server rechnet nach).
|
||||
- [ ] Bei gesendeter Version: keine Edit-Möglichkeit, klarer CTA „Neue Version".
|
||||
|
||||
---
|
||||
|
||||
### Ticket B4 — Dokumente am Angebot (zentral + frei)
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** B3
|
||||
|
||||
**Entscheidung 17.3:** Dual-Modell wie bei Buchungen.
|
||||
|
||||
**Dateien (neu/geändert):**
|
||||
- `app/Http/Controllers/OfferFileController.php` (Upload/Delete, analog zu `BookingController::action()` „upload-booking-file")
|
||||
- `resources/views/offer/_detail_documents.blade.php`
|
||||
- `resources/views/offer/modal-new-offer-files.blade.php` (Dropzone, nutzt gleiche Blade-Partials wie `booking/modal-new-booking-files`)
|
||||
|
||||
**UI:**
|
||||
Zwei Bereiche:
|
||||
1. **Zentral hinterlegte Dokumente** (Checkboxen):
|
||||
- Liste kommt aus einem neuen Mini-Modell `OfferDocumentTemplate` **oder** aus einer Konfig-Datei (`config/offer_documents.php`). Empfehlung: Konfig-Datei für den Start.
|
||||
- Beispiele: AGB, Reisebedingungen, Briefbogen Organisation X, Prospekt Reise Y.
|
||||
- Ausgewählte IDs liegen in `offer_versions.template_document_ids` (JSON).
|
||||
2. **Freie Uploads pro Version:**
|
||||
- Dropzone → `OfferFileController::upload($versionId)` → `OfferFileRepository`.
|
||||
- Liste der hochgeladenen Files mit Löschen-Button.
|
||||
- Nach Versand: `frozen=true` → Löschen-Button weg.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Upload funktioniert wie Booking-Upload (gleicher Dropzone-Partial wiederverwendet, falls machbar).
|
||||
- [ ] Gewählte zentrale Dokumente + freie Uploads werden zusammen mit PDF im Versand-Mail-Anhang kombiniert (Ticket B6).
|
||||
- [ ] Nach Versand: Dokumente read-only, markiert mit „Eingefroren".
|
||||
|
||||
**Nach Phase 4 Modul 3:** Migration `offer_files` → `attachments`-Tabelle, in diesem Ticket aber bewusst auf eigener Tabelle.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B5 — PDF-Generierung
|
||||
|
||||
**Typ:** Code + Template
|
||||
**Aufwand:** 2.5 Tage
|
||||
**Abhängigkeiten:** B3, B4
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Libraries/CreateOfferPDF.php` (analog zu `CreatePDF.php`)
|
||||
- `resources/views/pdf/offer.blade.php` (Haupt-Template)
|
||||
- `resources/views/pdf/offer/header.blade.php`, `footer.blade.php`, `items.blade.php`
|
||||
|
||||
**Logik:**
|
||||
- PDF wird für eine `OfferVersion` erzeugt (nicht für das übergeordnete `offer`).
|
||||
- Header: Logo, „Angebot 2026-00123 · Version 2", Kundenadresse, Mitarbeiter, Datum, Gültig bis.
|
||||
- Sektion „Headline" (falls gesetzt) — fett, groß.
|
||||
- Sektion „Einführungstext" — `{!! $version->intro_text !!}` (aus WYSIWYG).
|
||||
- Sektion „Leistungen" — Tabelle der `offer_items`, gruppiert nach Typ, Summe unten.
|
||||
- Sektion „Reiseverlauf" — nur wenn `itinerary_text` gefüllt.
|
||||
- Sektion „Abschlusstext".
|
||||
- Footer: Firmenadresse, Seite X/Y, Angebots-Nr.
|
||||
|
||||
**PDF-Mergen mit Anhängen:**
|
||||
- `MyPDFMerger` (existiert bereits) mergt erzeugtes PDF mit ausgewählten zentralen Dokumenten (die auf Disk liegen).
|
||||
- Endergebnis: ein einziges PDF-File, Pfad in `offer_versions.pdf_path`.
|
||||
|
||||
**Route:** `GET /offer/pdf/{versionId}` streamt das erzeugte PDF (Download oder Inline via Query `?inline=1`).
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] PDF für eine Beispiel-Version erzeugt sich korrekt, sieht wie Buchungsauftrag aus, nur mit „Angebot".
|
||||
- [ ] Merger funktioniert: Angebots-PDF + 2 zentrale Dokumente = 1 PDF.
|
||||
- [ ] Nach erfolgreicher Erzeugung wird `pdf_path` gespeichert; erneutes Abrufen nutzt Cache.
|
||||
- [ ] Bei Status-Wechsel (neue Version) wird altes PDF nicht überschrieben.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B6 — Versand via E-Mail
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** B5, Modul 7 (idealerweise Draft-Mail-Modal vorhanden, sonst eigenes Modal)
|
||||
|
||||
**Dateien (neu/geändert):**
|
||||
- `app/Mail/OfferMail.php` (Laravel Mailable)
|
||||
- `resources/views/emails/offer/sent.blade.php` (HTML-Template)
|
||||
- `resources/views/offer/modal-send-offer.blade.php` — Vorschau-Modal vor Versand (Empfänger, Betreff, Body bearbeitbar, Anhänge-Liste).
|
||||
- `OfferController::action('send', $id)` → delegiert an `OfferService::send()`.
|
||||
|
||||
**Logik:**
|
||||
1. User klickt „Versenden" → Modal mit vorbefülltem Betreff (`"Ihr persönliches Angebot von Sterntours — {offer_number}"`) und Body (aus CMS-Textvorlage, Platzhalter `{name}`, `{offer_number}`, `{link}`).
|
||||
2. User kann Betreff/Body anpassen.
|
||||
3. `OfferService::send()`:
|
||||
- Erzeugt neuen `OfferAccessToken`.
|
||||
- Ersetzt Platzhalter `{link}` durch die öffentliche URL aus Ticket D1.
|
||||
- Baut Mail mit PDF (aus B5) + freien Anhängen (aus B4) + zentralen Dokumenten.
|
||||
- Versendet per Queue (`Mail::to($contact->email)->queue(new OfferMail(...))`).
|
||||
- Erzeugt Eintrag in `communications` (nach Modul 3 Phase 4) bzw. `customer_mails` (Fallback solange Phase 4 offen).
|
||||
- Setzt `offer_versions.status=sent`, `sent_at`, `pdf_archived=false`.
|
||||
- Setzt `offers.status=sent`.
|
||||
- Setzt `frozen=true` auf `offer_files` der Version.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Mail landet bei Empfänger mit PDF + Link.
|
||||
- [ ] Token im Link ist einzigartig und nicht erratbar (≥ 48 Zeichen Entropie).
|
||||
- [ ] Versand wird im Kommunikations-Verlauf des Kontakts sichtbar.
|
||||
- [ ] Bei Queue-Fehler → Status bleibt `draft`, Fehler im Dashboard anzeigbar.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B7 — Admin-Annahme/Ablehnung/Zurücknahme
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** B6
|
||||
|
||||
**Dateien (neu):**
|
||||
- `resources/views/offer/modal-admin-accept.blade.php`
|
||||
- `resources/views/offer/modal-admin-decline.blade.php`
|
||||
|
||||
**UI:**
|
||||
- Im Detail-Header (Ticket B3) Buttons „Zusage markieren", „Absage markieren", „Angebot zurückziehen".
|
||||
- Bei Zusage: Modal fragt nach Notiz (optional), ruft `OfferService::markAccepted($v, 'admin')` auf.
|
||||
- Bei Absage: Modal fragt nach Grund (optional), ruft `markDeclined($v, 'admin')`.
|
||||
- Zurückziehen: `OfferService::withdraw()`; invalidiert Token, setzt Offer.status=withdrawn.
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Zusage/Absage-Events werden in `notices`/Audit-Log festgehalten.
|
||||
- [ ] Button „In Buchung übernehmen" wird erst nach Zusage aktiv.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B8 — Angebot → Buchung konvertieren
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** B7, **Modul 4 (BookingService::createManual)**
|
||||
|
||||
**Dateien (neu/geändert):**
|
||||
- `app/Services/OfferService.php` — `convertToBooking()` fertigstellen.
|
||||
- `resources/views/offer/modal-convert-to-booking.blade.php` — Bestätigungs-Modal mit Vorschau der Buchungsdaten.
|
||||
|
||||
**Logik:**
|
||||
- `convertToBooking(Offer $offer): Booking`:
|
||||
1. Guard: Offer.status=accepted, Offer.booking_id IS NULL.
|
||||
2. Baut `$bookingData` aus aktueller OfferVersion (Items → BookingServiceItems, Summe → BookingPrice, Kontakt → Customer, Reisedatum → aus Items oder aus Offer-Meta).
|
||||
3. Ruft `BookingService::createManual($bookingData, $offer->inquiry)` auf (Modul 4).
|
||||
4. Kopiert `offer_files` (frozen) als `booking_files`.
|
||||
5. Setzt `offer.booking_id`, persistiert.
|
||||
6. Legt Notiz an: „Erzeugt aus Angebot 2026-00123 V2".
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Nach Konvertierung ist in der Buchung alles sichtbar: Kontakt, Positionen, Dokumente.
|
||||
- [ ] Angebot bleibt erhalten, verlinkt auf neue Buchung; Status bleibt `accepted`.
|
||||
- [ ] Idempotent: zweiter Aufruf schlägt mit klarer Fehlermeldung fehl.
|
||||
|
||||
---
|
||||
|
||||
### Ticket B9 — Offer-Löschen + Soft Delete
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** B1
|
||||
|
||||
**Dateien (geändert):**
|
||||
- `OfferController::delete()`, Confirm-Modal `offer/modal-delete.blade.php`.
|
||||
|
||||
**Regeln:**
|
||||
- Soft-Delete nur möglich, wenn Offer.booking_id IS NULL.
|
||||
- Sonst Error-Toast „Dieses Angebot ist mit Buchung {id} verknüpft und kann nicht gelöscht werden. Bitte zuerst Buchung entfernen."
|
||||
- Alternativ Status `withdrawn` wählen.
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Vorlagen (Offer-Templates)
|
||||
|
||||
### Ticket C1 — Template-Liste + Editor
|
||||
|
||||
**Typ:** UI + Controller
|
||||
**Aufwand:** 1.5 Tage
|
||||
**Abhängigkeiten:** A3, A6
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Http/Controllers/OfferTemplateController.php`
|
||||
- `resources/views/offer_template/index.blade.php`
|
||||
- `resources/views/offer_template/detail.blade.php`
|
||||
|
||||
**Felder identisch zu OfferVersion (ohne Kontakt/Summe)**. Positionen werden in `items_json` gespeichert.
|
||||
|
||||
### Ticket C2 — „Als Vorlage speichern" aus Angebot
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** C1, B3
|
||||
|
||||
- Button im Offer-Header „Als Vorlage speichern" → öffnet Modal (Name, Organisation-Auswahl) → `OfferTemplateRepository::createFromVersion($v, $name, $org)`.
|
||||
|
||||
### Ticket C3 — Gruppierung nach Reise-Organisation
|
||||
|
||||
**Typ:** UI
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** C1
|
||||
|
||||
- Dropdown im B2-Modal gruppiert Vorlagen nach `organization`.
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Kundenseitiger Freigabe-Link
|
||||
|
||||
### Ticket D1 — Öffentliche Route auf sterntours.de
|
||||
|
||||
**Typ:** Code (Symfony oder Laravel — Entscheidung unten)
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** B6
|
||||
|
||||
**Entscheidung:** Die öffentliche Seite läuft auf **`mein.sterntours.de/angebot/{token}`** (Laravel), nicht im Symfony-Frontend. Begründung: Token-Handling und Laravel-Auth-Scaffolding liegen ohnehin in Laravel, doppelter Frontend-Bau im Legacy-System wäre Mehraufwand. URL kann per Subdomain `angebote.sterntours.de` gebrandet werden (Traefik-Route).
|
||||
|
||||
**Dateien (neu):**
|
||||
- `routes/web.php` — neuer Block ohne `auth.2fa`-Middleware:
|
||||
```php
|
||||
Route::group(['middleware' => ['web']], function () {
|
||||
Route::get('/angebot/{token}', 'Public\OfferAccessController@show')->name('public_offer_show');
|
||||
Route::post('/angebot/{token}/accept', 'Public\OfferAccessController@accept')->name('public_offer_accept');
|
||||
Route::post('/angebot/{token}/decline','Public\OfferAccessController@decline')->name('public_offer_decline');
|
||||
});
|
||||
```
|
||||
- `app/Http/Controllers/Public/OfferAccessController.php`
|
||||
- `app/Http/Middleware/ValidOfferToken.php` (prüft Existenz, expiry; 404 sonst; setzt `$request->attributes->set('offerVersion', …)`)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Ungültiger/abgelaufener Token → 404 mit freundlicher Fehlerseite.
|
||||
- [ ] Gültiger Token → `opened_at` wird beim ersten Aufruf gesetzt.
|
||||
|
||||
### Ticket D2 — Kunden-Ansichtsseite
|
||||
|
||||
**Typ:** UI
|
||||
**Aufwand:** 1.5 Tage
|
||||
**Abhängigkeiten:** D1
|
||||
|
||||
**Dateien (neu):**
|
||||
- `resources/views/public/offer/show.blade.php` — eigenes, schlankes Layout (nicht Admin-Sidebar).
|
||||
- `resources/views/public/offer/_summary.blade.php`
|
||||
- `resources/views/public/offer/_actions.blade.php`
|
||||
- `resources/views/public/offer/accepted.blade.php` (Danke-Seite nach Annahme)
|
||||
- `resources/views/public/offer/declined.blade.php`
|
||||
|
||||
**Inhalt:**
|
||||
- Begrüßung („Guten Tag Frau Musterfrau").
|
||||
- PDF-Embed (iframe oder viewer.js).
|
||||
- Download-Button PDF.
|
||||
- Anhänge-Liste zum Download.
|
||||
- Buttons „Angebot annehmen" / „Angebot ablehnen" / „Ich habe Fragen".
|
||||
- Bei Annahme: Bestätigungs-Dialog („Sind Sie sicher? Mit Ihrer Bestätigung erfolgt die verbindliche Annahme."), dann POST.
|
||||
|
||||
### Ticket D3 — Annahme-/Ablehnungs-Flow
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** D2
|
||||
|
||||
- `OfferAccessController::accept()`: prüft Token, ruft `OfferService::markAccepted($v, 'customer_link', $ip, $ua)`, sendet interne Benachrichtigung an zuständigen Mitarbeiter (Mail + Dashboard-Notice).
|
||||
- `OfferAccessController::decline()`: optionales Grund-Feld, `markDeclined()`.
|
||||
- Token wird nach Annahme/Ablehnung invalidiert (`accepted_at`/`declined_at` gesetzt, erneuter Aufruf zeigt Danke-Seite mit Historie).
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Nach Annahme erhält Mitarbeiter eine Mail + Dashboard-Notiz.
|
||||
- [ ] Kunde kann bei wiederholtem Öffnen des Links die Danke-Seite sehen, aber nicht erneut annehmen.
|
||||
- [ ] IP/User-Agent werden protokolliert.
|
||||
|
||||
### Ticket D4 — „Ich habe Fragen"-Kanal
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** D2
|
||||
|
||||
- Button öffnet Modal mit Textarea.
|
||||
- Submit legt neue `communication` an (oder bis Modul 3 Phase 4 fertig: `lead_mail`/`customer_mail`) mit Richtung „eingehend", `from=contact.email`, verknüpft mit Offer.
|
||||
|
||||
### Ticket D5 — Rate-Limiting + CSRF-Tokens
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** D1
|
||||
|
||||
- `RateLimiter::for('offer-access', …)` — max 30 Requests/Minute pro IP.
|
||||
- CSRF bleibt an (public Route in `web`-Group).
|
||||
- Token-Rate-Limit: max 5 falsche Token-Versuche/IP/Minute (schützt vor Brute-Force).
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Versionierung & Archivierung
|
||||
|
||||
### Ticket E1 — „Neue Version" Flow
|
||||
|
||||
**Typ:** Code + UI
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** B6, A4
|
||||
|
||||
- Button „Neue Version" im Detail-Header einer gesendeten Version.
|
||||
- `OfferService::supersede()` → `OfferVersionRepository::createNewVersion()` (A3).
|
||||
- UI lädt direkt die neue Version zum Editieren.
|
||||
- Alte Version bleibt sichtbar im Versions-Tab (Ticket B3 `_detail_versions.blade.php`), als Read-only, mit Badge „Überholt".
|
||||
|
||||
### Ticket E2 — Versions-Historie im Detail
|
||||
|
||||
**Typ:** UI
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** B3, E1
|
||||
|
||||
- Versions-Tab zeigt chronologische Liste aller `OfferVersion`s mit:
|
||||
- Version-Nr., Status-Badge, Erstellt am, Versendet am, Summe, PDF-Link.
|
||||
- Link „Diese Version ansehen" (read-only).
|
||||
|
||||
### Ticket E3 — PDF-Archivierung Cron
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** B5
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Console/Commands/OfferArchiveOldPdfs.php`
|
||||
|
||||
**Logik:**
|
||||
- Läuft nächtlich via `Console\Kernel::schedule()`.
|
||||
- Für jede Offer: PDFs aller Versionen außer der aktuellen + der zuletzt akzeptierten werden auf kalten Storage (`disk('offers-cold')`) verschoben, `pdf_archived=true`.
|
||||
- Konfigurierbar via `config/offers.php` (Retention-Limit, Disk-Name).
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] PDF-Abruf (Ticket B5 Route) generiert das PDF bei Bedarf neu, wenn `pdf_archived=true` und Datei nicht auf Hot-Disk.
|
||||
- [ ] Konfiguration über `config/offers.php` steuerbar.
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Polish, Automatik, Tests
|
||||
|
||||
### Ticket F1 — Zähler/Badges in Contacts und Inquiries
|
||||
|
||||
**Typ:** UI + Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** B1
|
||||
|
||||
- In `resources/views/contact/index.blade.php` neue Spalte „Angebote" mit Zähler-Badge (analog Anfragen/Buchungen aus Phase 1 Contacts).
|
||||
- Klick öffnet History-Modal (analog `_detail_history.blade.php`, Scope auf Offers).
|
||||
- Dito in `resources/views/lead/detail.blade.php` / `inquiry/detail.blade.php`: neue Sektion „Angebote zu dieser Anfrage".
|
||||
|
||||
### Ticket F2 — Ablauf-Automatik (expired-Cron)
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** A4
|
||||
|
||||
**Dateien (neu):**
|
||||
- `app/Console/Commands/OfferExpireVersions.php`
|
||||
|
||||
**Logik:**
|
||||
- Täglich um 03:00: setzt alle `OfferVersion` mit `valid_until < today` und `status=sent` auf `expired`.
|
||||
- Wenn Offer keine aktiven Versionen mehr hat → Offer.status=expired.
|
||||
- Optional: Erinnerung 3 Tage vor Ablauf an zuständigen Mitarbeiter (Feature-Flag).
|
||||
|
||||
### Ticket F3 — Communications-Integration
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 1 Tag
|
||||
**Abhängigkeiten:** Modul 3 Phase 4 (nach Abschluss), B6, D4
|
||||
|
||||
- Offer-bezogene E-Mails werden mit `offer_id` (und `offer_version_id`) in `communications` hinterlegt.
|
||||
- Detail-View Ticket B3 bekommt eigenen Tab „E-Mails" mit den zugehörigen Nachrichten.
|
||||
- Wenn Kunde über Freigabe-Link antwortet (D4): Notification landet ebenfalls im gleichen Verlauf.
|
||||
|
||||
### Ticket F4 — Feature-Tests
|
||||
|
||||
**Typ:** Tests
|
||||
**Aufwand:** 2 Tage
|
||||
**Abhängigkeiten:** alle
|
||||
|
||||
**Dateien (neu):**
|
||||
- `tests/Feature/Offer/OfferCrudTest.php`
|
||||
- `tests/Feature/Offer/OfferWorkflowTest.php` (draft → sent → accepted → converted)
|
||||
- `tests/Feature/Offer/OfferVersioningTest.php` (supersede, history)
|
||||
- `tests/Feature/Offer/OfferPublicAccessTest.php` (Token-Flow)
|
||||
- `tests/Feature/Offer/OfferPdfTest.php` (PDF wird erzeugt, enthält Angebots-Nr.)
|
||||
- `tests/Unit/Services/OfferServiceTest.php`
|
||||
|
||||
**Coverage-Ziel:** ≥ 80 % für `OfferService` + `OfferRepository`.
|
||||
|
||||
### Ticket F5 — Dokumentation
|
||||
|
||||
**Typ:** Doku
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** alle
|
||||
|
||||
- `dev/offers/README.md` mit Funktionsübersicht.
|
||||
- `dev/offers/user-guide.md` — Anleitung für Mitarbeiter (mit Screenshots).
|
||||
- Eintrag in `CLAUDE.md` / `mein.sterntours.de/CLAUDE.md` für künftige AI-Assistenz.
|
||||
|
||||
### Ticket F6 — Permissions-Review + Seeder-Update
|
||||
|
||||
**Typ:** Code
|
||||
**Aufwand:** 0.5 Tage
|
||||
**Abhängigkeiten:** A6
|
||||
|
||||
- Seeder legt Permissions `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w` an.
|
||||
- Bestehende Rollen (Admin, Mitarbeiter, Buchhaltung) werden mit sinnvoller Default-Zuweisung versehen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Umsetzungs-Reihenfolge (Sprint-Vorschlag, 2-Wochen-Sprints)
|
||||
|
||||
| Sprint | Tickets | Ziel |
|
||||
|--------|---------|------|
|
||||
| S1 | A1–A7 | Fundament steht, leere Offer-Liste im Backend erreichbar |
|
||||
| S2 | B1–B3 | Angebot anlegen & bearbeiten (ohne Versand) |
|
||||
| S3 | B4–B6 | Dokumente + PDF + Versand funktionieren |
|
||||
| S4 | B7–B9, E1, E2 | Annahme/Ablehnung, Versionierung |
|
||||
| S5 | C1–C3, D1–D5 | Vorlagen-Verwaltung + Kundenseite (Parallel möglich) |
|
||||
| S6 | E3, F1–F6, B8 | Archivierung, Integration in Contacts/Inquiries, Angebot→Buchung, Tests, Doku |
|
||||
|
||||
**Meilenstein „MVP verwendbar" nach S3**: Mitarbeiter können ein Angebot anlegen, PDF erzeugen und versenden — Annahme zunächst nur per Admin-Statuswechsel.
|
||||
|
||||
**Meilenstein „Vollversion" nach S6**: Inklusive Kundenportal-Link, Versionierung, Archivierung, Konvertierung in Buchung, Tests.
|
||||
|
||||
---
|
||||
|
||||
## 3. Risiken und Gegenmaßnahmen
|
||||
|
||||
| # | Risiko | Wahrscheinlichkeit | Wirkung | Gegenmaßnahme |
|
||||
|---|--------|--------------------|---------|----------------|
|
||||
| R1 | `BookingService::createManual()` aus Modul 4 noch nicht fertig, wenn B8 dran ist | Mittel | B8 blockiert | Mocking in S4, echte Integration in S6 |
|
||||
| R2 | PDF-Generierung performance-kritisch bei vielen Anhängen (PDFMerger) | Niedrig | PDF-Erstellung dauert > 10 s | Queue-basierte Erzeugung (async Job), UI zeigt „Wird erstellt …" |
|
||||
| R3 | Token-URLs werden öffentlich geteilt (Social Media etc.) | Mittel | Unerwünschter Einblick | Token-Expiry 14 Tage Standard, Viewer-Log zeigt auffälliges Öffnen |
|
||||
| R4 | `travel_program_id` in `offer_items` zeigt auf v2-Daten, die wegmigrieren | Hoch | Datenbezüge brechen nach v2-Migration | Metadata speichert Titel/Preis zum Zeitpunkt der Erstellung — funktioniert auch bei fehlendem FK |
|
||||
| R5 | Mitarbeiter bearbeiten ein gesendetes Angebot unbewusst (Auto-Save) | Mittel | Inkonsistente Versionen | Auto-Save nur bei `isEditable()==true`, sonst Felder disabled |
|
||||
| R6 | Konflikt mit Modul 3 Phase 4 (attachments) | Hoch | Doppelte Speicherorte | Eigene `offer_files`-Tabelle jetzt, Migration in `attachments` ist eigenes Ticket nach Phase 4 — **bewusst so geplant** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Offene Detail-Entscheidungen (können während Umsetzung geklärt werden)
|
||||
|
||||
1. **Angebots-Nummernkreis:** `{YYYY}-{5-stellig}` oder durchlaufend ohne Jahr? → Vorschlag: mit Jahr (leichter zu sortieren, klassisches Format).
|
||||
2. **Subdomain für Kundenseite:** `/angebot/{token}` auf `mein.sterntours.de` (einfacher) oder `angebote.sterntours.de` (professioneller für Kunden)? → Vorschlag: Subdomain, da Kunden nicht den Admin-Hostnamen sehen sollten. Technisch ist es dieselbe Laravel-App.
|
||||
3. **WYSIWYG-Editor:** TinyMCE (bereits im System für CMS) oder TipTap (moderner, lizenzfrei)? → Vorschlag: TinyMCE, da schon integriert.
|
||||
4. **PDF-Engine:** TCPDF / DomPDF (beide im System, siehe `app/Libraries/`) oder weiterer Kandidat (wkhtmltopdf)? → Vorschlag: DomPDF, da bereits für Booking-PDFs genutzt; konsistente Optik.
|
||||
5. **Token-Gültigkeit Default:** 14 Tage, 30 Tage, bis `valid_until`? → Vorschlag: bis `valid_until + 7 Tage` (Kunde soll nach Ablaufdatum noch die Ablehnungs-Bestätigung sehen können).
|
||||
|
||||
Diese 5 Punkte würde ich Ende S1 (nach Fundament) kurz final abstimmen, damit S2 sauber losfahren kann.
|
||||
|
||||
---
|
||||
|
||||
## 5. Abnahmeliste (Gesamt-Abschluss Modul 6)
|
||||
|
||||
- [ ] Mitarbeiter kann aus Anfrage heraus in < 2 Minuten ein Angebot mit Vorlage erstellen.
|
||||
- [ ] Mitarbeiter kann PDF generieren und ansehen.
|
||||
- [ ] Mitarbeiter kann Angebot per E-Mail versenden; Kunde erhält PDF + Link.
|
||||
- [ ] Kunde kann über Link das Angebot ansehen und mit einem Klick annehmen oder ablehnen.
|
||||
- [ ] Mitarbeiter sieht Annahme im Dashboard und bekommt Benachrichtigung.
|
||||
- [ ] Aus angenommenem Angebot kann mit einem Klick eine Buchung erzeugt werden.
|
||||
- [ ] Nach Versand ist das Angebot read-only; Änderungen erzeugen eine neue Version.
|
||||
- [ ] Alte Versionen bleiben auffindbar und ihr PDF weiterhin downloadbar.
|
||||
- [ ] In Kontakt- und Anfrage-Ansicht ist die Anzahl der Angebote sichtbar und klickbar.
|
||||
- [ ] Abgelaufene Angebote werden automatisch auf `expired` gesetzt.
|
||||
- [ ] Alle neuen Routen haben Permission-Checks.
|
||||
- [ ] Feature-Tests grün, Unit-Test-Coverage ≥ 80 % im Service/Repository.
|
||||
- [ ] Dokumentation für Entwickler (`dev/offers/README.md`) und für Mitarbeiter (`user-guide.md`) vorhanden.
|
||||
138
dev/projekt-empfehlungen-2026-04.md
Normal file
138
dev/projekt-empfehlungen-2026-04.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Projekt-Analyse: sinnvolle Erweiterungen und Optimierungen (mein.sterntours.de)
|
||||
|
||||
Stand: April 2026. Dieses Dokument ergänzt und aktualisiert die frühere Übersicht in [`audit-april-2025.md`](./audit-april-2025.md) und fasst zusätzliche Beobachtungen aus Code-Struktur, Abhängigkeiten und typischen Laravel-Betriebsmustern zusammen.
|
||||
|
||||
---
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
Das CRM ist ein ausgereiftes **Laravel-10**-Monolith mit klarer Schichtung (Controller → Request/Repository → Services/Models), **Zwei-Datenbank-Setup** (CRM + read-only Legacy) und **Passport** für API-Bereiche. Die größten Hebel für die nächsten Monate sind: **Wartbarkeit** (Duplikate reduzieren, moderne Laravel-Konventionen), **Sicherheit** (öffentliche API-Endpunkte härten), **Qualität** (Tests, statische Analyse), **Frontend-Tooling** (langfristig von Laravel Mix 2 weg) und **Betrieb** (Scheduler, Monitoring, asynchrone Mail).
|
||||
|
||||
---
|
||||
|
||||
## Abgleich mit dem Audit April 2025
|
||||
|
||||
| Thema | Früher (Audit) | Aktueller Stand (Code-Review) |
|
||||
|--------|------------------|-------------------------------|
|
||||
| API-Key Booking-Import | Hardcoded im Controller | **Erledigt im Sinne von Konfiguration:** `API/BookingController` nutzt `config('app.success_key')` → `env('SUCCESS_KEY')` in `config/app.php`. Sicherstellen, dass `.env`/`SUCCESS_KEY` in allen Umgebungen gesetzt und **nicht** ins Repo gelangt. |
|
||||
| PHPUnit / SQLite zweite DB | `mysql_stern` fehlt in Tests | **Weiterhin relevant:** In `phpunit.xml` sind keine `DB_*`-Variablen für die Stern-Verbindung gesetzt; Tests mit Legacy-Models bleiben erschwert. |
|
||||
| Frontend (Mix 2, node-sass) | Veraltet | **Unverändert:** `package.json` mit Laravel Mix **2.1** und sehr altem Ökosystem – größeres Migrationsprojekt. |
|
||||
| Code-Duplizierung Booking/Lead (Mail-Verzeichnisse) | Offen | **Weiterhin sinnvoll:** Gemeinsamen Service/Trait extrahieren. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur und Wartbarkeit
|
||||
|
||||
### 1.1 Gemeinsame Logik zusammenführen
|
||||
|
||||
- **Mail-Verzeichnis-Logik** in `Services/Booking.php` und `Services/Lead.php` (siehe Audit): Ziel sollte ein klar benannter **`MailDirService`** (oder Trait nur, wenn wirklich nur technische Wiederholung) sein, inklusive Tests.
|
||||
- **Parallele Mail-Repositories** (`CustomerMailRepository`, `LeadMailRepository`, `CustomerFewoMailRepository`): `sendMail`/`prepareMessageFull` sind sich sehr ähnlich – mittelfristig Basis-Klasse oder gemeinsames **„Mail senden mit Anhängen/Fehlerstatus“**-Modul prüfen (ohne Big-Bang-Refactoring).
|
||||
|
||||
### 1.2 Laravel-Konventionen und Typisierung
|
||||
|
||||
- Controller-Methoden mit **`Illuminate\Http\Request` injizieren** statt `\Request::all()` (globale Facade), wo möglich – verbessert Testbarkeit und IDE-Unterstützung.
|
||||
- **`BaseRepository` und ältere Services** schrittweise mit **Return-Types** und strengeren Parametertypen versehen (Audit erwähnt das bereits).
|
||||
- API-Routen nutzen noch **String-Controller-Referenzen** (`'API\BookingController@import'`). Migration auf **Klassen-Syntax** (`[BookingController::class, 'import']`) erleichtert spätere Laravel-11/12-Upgrades und Refactoring-Tools.
|
||||
|
||||
### 1.3 Offene TODOs gezielt abarbeiten
|
||||
|
||||
Im Projekt existieren zahlreiche `//TODO`-Marker (u. a. CMS, Settings „linked“-Prüfungen, Mail-Modals „load subdirs by pos id“, IQ-ContentTree). Sinnvoll ist ein **kleines Backlog** nach Kategorie (Bug, UX-Schuld, Sicherheit) – nicht alles auf einmal, aber sichtbar machen, damit keine stillen Lücken bleiben.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sicherheit
|
||||
|
||||
### 2.1 Öffentliche API-Endpunkte
|
||||
|
||||
- **`POST /api/navigation/cache/clear`** ist ohne `auth:api` definiert. Jeder kann damit theoretisch den Navigations-Cache leeren (DoS/Cache-Störung). **Empfehlung:** Secret-Header, IP-Allowlist, oder **nur authentifizierte** Aufrufer (je nach Aufrufer-Architektur).
|
||||
- **`booking/import`** akzeptiert laut Routen **GET und POST**. GET-Requests mit Seiteneffekten (Import) sind problematisch (Logs, Proxies, prefetch). **Empfehlung:** nur **POST**, dokumentieren; Legacy-GET nur temporär mit Deprecation-Header/Log.
|
||||
- Booking-Import: Neben dem Shared Secret **Rate-Limiting** prüfen (eigener `RateLimiter::for('booking-import', …)`), falls der Endpunkt von wenigen festen IPs kommt: zusätzlich **IP-Bindung** in Middleware.
|
||||
|
||||
### 2.2 API allgemein
|
||||
|
||||
- Öffentliche Routen (`cms/*`, `passolution`, `navigation/*`) sollten einmal **inventarisiert** werden: Welche liefern personenbezogene oder interne Daten? Braucht es **API-Versionierung** (`/api/v1/...`) für externe Konsumenten?
|
||||
|
||||
### 2.3 Abhängigkeiten
|
||||
|
||||
- In `composer.json` stehen mehrere Pakete mit **`"*"`** (z. B. `iqcontent/laravel-filemanager`, `jenssegers/date`, …). **Empfehlung:** auf **konkrete Versionen** pinnen, um reproduzierbare Builds und Sicherheits-Updates zu gewährleisten.
|
||||
|
||||
---
|
||||
|
||||
## 3. Performance und Skalierung
|
||||
|
||||
### 3.1 E-Mail synchron im Request
|
||||
|
||||
Versand erfolgt in Repositories per `Mail::…->send()` im gleichen Prozess wie der HTTP-Request. Unter Last verlängert das Antwortzeiten.
|
||||
|
||||
- **Kurz:** `Mail::queue()` bzw. **Queued Mailables** / **`ShouldQueue`** für nicht-kritische Benachrichtigungen.
|
||||
- **Voraussetzung:** zuverlässiger **Queue-Worker** (Supervisor/Docker) und Monitoring fehlgeschlagener Jobs.
|
||||
|
||||
### 3.2 Scheduler
|
||||
|
||||
`app/Console/Kernel.php` enthält einen **leeren** `schedule()`-Block. Wenn Cron-Jobs extern oder in anderer Form laufen, **dokumentieren**; sonst wiederkehrende Tasks (Newsletter-Sync, Bereinigungen, Cache-Warmup) **hier** oder als dokumentierte `artisan`-Crons abbilden – sonst droht „vergessene“ Automatisierung bei Serverwechsel.
|
||||
|
||||
### 3.3 Caching
|
||||
|
||||
- Navigation nutzt bereits Caching im Service – gut. **Strategie:** TTLs, Cache-Tags (falls Redis/Memcached), und Invalidierung nur über vertrauenswürdige Wege (siehe 2.1).
|
||||
|
||||
---
|
||||
|
||||
## 4. Qualität, Tests und Entwicklererfahrung
|
||||
|
||||
### 4.1 Testabdeckung
|
||||
|
||||
Aktuell ein **kleines Set** Feature/Unit-Tests (siehe Audit-Liste). Sinnvolle nächste Schritte:
|
||||
|
||||
- **Factories** für zentrale Domänenobjekte (`Customer`, `Booking`, `Lead`) – Audit nennt das bereits.
|
||||
- **Zweite DB in PHPUnit** konfigurieren (Audit-Vorschlag mit SQLite zweiter Connection), damit Legacy-Models überhaupt testbar werden.
|
||||
- Kritische **API-Verträge** (Booking-Import, Fewo-API) mit **Contract-Tests** absichern.
|
||||
|
||||
### 4.2 Statische Analyse und Formatierung
|
||||
|
||||
- **Laravel Pint** (oder PHP-CS-Fixer) für einheitlichen Style – aktuell keine `pint.json` im Projektroot sichtbar.
|
||||
- **PHPStan/Larastan** (Level schrittweise erhöhen) für frühe Fehler in Repositories und Services.
|
||||
|
||||
### 4.3 Observability
|
||||
|
||||
- Zentral **strukturiertes Logging** für API-Fehler (ohne Secrets), optional eigener Log-Channel für `booking/import`.
|
||||
- Langfristig: **Laravel Pulse** oder APM (wenn nicht schon vorhanden) für langsame Requests und Queue-Latenzen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend und Assets
|
||||
|
||||
Unverändert zur Einschätzung im Audit:
|
||||
|
||||
- **Laravel Mix 2**, **Bootstrap 4**, **node-sass** – technisch am Ende der Fahnenstange.
|
||||
- **Sinnvolle Richtung:** Migration auf **Vite** + **sass (dart-sass)** + schrittweise **Bootstrap 5** oder gezieltes Design-System – als **eigenes Projekt** mit Pilot-View und Build-Pipeline in CI.
|
||||
|
||||
---
|
||||
|
||||
## 6. Framework- und PHP-Roadmap
|
||||
|
||||
- **Laravel 10** ist LTS-fähig; mittelfristig **Upgrade-Pfad zu Laravel 11** planen (Routing, `bootstrap/app.php`, strukturelle Änderungen).
|
||||
- **`jenssegers/date`** ist überflüssig, wenn überall **Carbon** genutzt wird – Dependency reduzieren.
|
||||
- **PHP:** `composer.json` erlaubt 8.1–8.3; einheitliche **8.2/8.3**-Zielversion in CI und Docker festlegen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Priorisierte Maßnahmen (Vorschlag)
|
||||
|
||||
| Priorität | Maßnahme | Aufwand |
|
||||
|-----------|----------|---------|
|
||||
| Hoch | `navigation/cache/clear` absichern (Auth/Secret/IP) | Klein–mittel |
|
||||
| Hoch | `booking/import`: nur POST, Rate-Limit; Aufrufer-Doku | Klein |
|
||||
| Hoch | Composer-`**`-Versionen durch feste Versionen ersetzen | Mittel |
|
||||
| Mittel | `MailDirService` / Duplikate Booking–Lead | Mittel |
|
||||
| Mittel | Mail-Versand für geeignete Fälle über Queue | Mittel–groß |
|
||||
| Mittel | PHPUnit: zweite DB + mehr Factories | Mittel |
|
||||
| Mittel | Pint + Larastan einführen (CI) | Mittel |
|
||||
| Niedrig | API auf Klassen-Routen, Request-Injection ausbreiten | Laufend |
|
||||
| Strategisch | Frontend: Vite + Tooling-Modernisierung | Groß |
|
||||
|
||||
---
|
||||
|
||||
## Referenz
|
||||
|
||||
- Bestehende Detail-Audits und Test-Kommandos: [`audit-april-2025.md`](./audit-april-2025.md)
|
||||
- UI-/Navigations-Kontext: [`frontend-navigation/BACKEND-UI.md`](./frontend-navigation/BACKEND-UI.md)
|
||||
Loading…
Add table
Add a link
Reference in a new issue