Neustrukturierung Customer / Lead / Booking Phase 2

This commit is contained in:
Kevin Adametz 2026-05-28 17:10:37 +02:00
parent 313f0dbf4e
commit 6df9c401af
69 changed files with 3809 additions and 374 deletions

View file

@ -1,16 +1,127 @@
# Modul 6 — Angebote: Implementierungs-Tickets
**Status:** Entwurf, bereit zur Umsetzung
**Status:** In Umsetzung (A7 abgeschlossen, Phase B als Nächstes)
**Erstellt:** April 2026
**Letzte Aktualisierung:** 2026-04-24
**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 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren. ✅ Test
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`. ⬜ Fallback gewählt
- 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.
---
## Fortschritt
| Ticket | Status | Datum | Notizen |
|--------|--------|-------|---------|
| A1 — Datenbank-Migrationen (7 Files) | ✅ Test (Batch 34) | 2026-04-18 | FK-Typ-Fix nötig, siehe A1-Lessons |
| A2 — Eloquent-Models (6 Files) | ✅ Test | 2026-04-18 | Smoke-Test grün, siehe A2-Lessons |
| A3 — Repositories (4 Files) | ✅ Test | 2026-04-18 | Smoke-Test 9/9 grün, siehe A3-Lessons |
| A4 — OfferService + Exceptions + Events | ✅ Test | 2026-04-18 | Smoke-Test 13/13 grün, siehe A4-Lessons |
| A5 — FormRequests (4 Files) | ✅ | 2026-04-18 | `app/Http/Requests/Offer/*`, siehe A5-Lessons |
| A6 — Routing + Permissions + Sidenav | ✅ | 2026-04-24 | 15 Routen, 5 Rechte, siehe A6-Lessons |
| A7 — Factories + Seeder | ✅ | 2026-04-24 | `ContactFactory` + 4 Offer-Factories + `OfferTemplateSeeder`, siehe A7-Lessons |
### A1 — Lessons Learned (Test-Migration 2026-04-18)
1. **FK-Typ-Mismatch:** Spec/erste Version nutzte `foreignId(...)``bigint unsigned`. Die Legacy-Tabellen haben aber:
- `contacts.id`, `inquiries.id`, `booking.id`, `branch.id``bigint` **(signed!)**
- `users.id``int unsigned`
Fix in 3 Migrationen (100001, 100002, 100004): explizit `$t->bigInteger('contact_id')` etc. + manueller `$t->foreign(...)->references('id')->on('xxx')`. Für `users``unsignedInteger('created_by')`.
2. **Migrations-Pfad-Trick:** Das Projekt hat viele alte Pending-Migrationen, deren Tabellen längst existieren (Sequel-Pro-Export-Historie). `php artisan migrate --force` (ohne Pfad) schlägt deswegen fehl. Workaround:
```bash
mkdir -p storage/migrations-tmp/offers
for f in database/migrations/2026_04_17_*.php; do
ln -sf "$(realpath $f)" "storage/migrations-tmp/offers/$(basename $f)"
done
php artisan migrate --path=storage/migrations-tmp/offers --realpath --force
```
So werden ausschließlich die 7 Offer-Files geladen.
3. **Class-Name-Bug entdeckt & gefixt:** `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php` deklarierte fälschlicherweise `class CreateBookingVoucherTable` (richtig: `CreateBookingVoucherAgencyTable`). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen.
4. **Verifikation OK:** 15 neue FKs gesetzt (5 auf `offers`, 3 auf `offer_versions`, je 12 auf andere + 1 Reverse auf `booking.offer_id`), Indizes/Unique-Constraints alle vorhanden. Siehe `SHOW CREATE TABLE`-Outputs in den Smoke-Tests.
5. **Akzeptanzkriterium „migrate:fresh --seed reproduzierbar":** Auf Test nicht ausführbar (würde DB löschen). Empfehlung: in CI auf einer Throwaway-DB testen, sobald CI-Pipeline existiert.
### Live-Deploy von A1 (TODO später)
Wenn A2-A7 fertig sind und das Modul deploybar ist, müssen für Live mit:
- 7 Migrationen aus `database/migrations/2026_04_17_100001` bis `100007`
- 1 Klassennamen-Fix aus `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php`
ein eigenes Live-Deploy-Handbuch geschrieben werden (analog `phase-2-live-deploy.md`).
### A2 — Lessons Learned (2026-04-18)
1. **6 Models angelegt**: `Offer`, `OfferVersion`, `OfferItem`, `OfferTemplate`, `OfferFile`, `OfferAccessToken` in `app/Models/`. Alle mit `HasFactory`, `$connection='mysql'`, typisierten PHPDoc-Annotations, `STATUS_*`-Konstanten (statt String-Literals) und sauberen `BelongsTo`/`HasMany`-Relations.
2. **Status-Konstanten:** Stati sind als `self::STATUS_DRAFT` etc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl. `superseded`). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet und `whereIn` mit Strings gut funktioniert.
3. **OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self** erzeugt `Str::random(64)`-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Property `plain_token`. Counterpart `findActiveByPlainToken(string)` hasht den eingehenden Klartext und scoped auf `active()` (nicht widerrufen, nicht abgelaufen).
4. **OfferFile API-kompatibel zu BookingFile**: Gleiche Method-Signaturen (`getURL`, `getPath`, `getIconExt`, `formatBytes`) — so können Blade-Partials/Dropzone-Helper aus dem Booking-Modul geteilt werden. Unterschied: Spalte `include_in_pdf` statt `frozen` (semantisch klarer).
5. **Smoke-Test (Tinker, in Transaktion + Rollback)**: Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading `currentVersion.items / .tokens / contact` grün, `totalPriceFormatted() → "1.234,56 €"`, `isEditable() → true` für Draft.
6. **Offer-Inquiry-Relation zeigt auf Lead-Model**: Die Tabelle heißt nach Phase 2 `inquiries`, das Eloquent-Model ist aber weiterhin `App\Models\Lead` (Umbenennung des Models ist eigenes Ticket). Offer verwendet `$this->belongsTo(Lead::class, 'inquiry_id')`.
### A3 — Lessons Learned (2026-04-18)
1. **4 Repositories angelegt**: `OfferRepository`, `OfferVersionRepository`, `OfferTemplateRepository` (extends `BaseRepository`) und `OfferFileRepository` (extends `FileRepository`). Alle mit `use Illuminate\Support\Facades\DB;` — konsistent mit Phase-1-Stil der `ContactsMergeDuplicates`-Command.
2. **`generateOfferNumber()` race-safe umgesetzt**: `lockForUpdate()` auf der höchsten `offer_number LIKE '{year}-%'`-Zeile innerhalb einer `DB::transaction`; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordert `YYYY-NNNNN`.
3. **`OfferRepository::create()` atomar**: Offer + Version#1 in einem `DB::transaction`-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.
4. **`createNewVersion()` dupliziert via `replicate()`**: Methode nutzt Eloquents `replicate(['except-cols'])`, um Items + Files als neue Zeilen zu kopieren. Physische Datei-Binaries werden **nicht** dupliziert — die Datei-Zeile bekommt nur eine neue DB-ID. Falls später einzelne Versionen gelöscht werden, muss die Delete-Logik prüfen, ob andere Versionen noch auf dieselbe Datei referenzieren (Aufgabe für B4/B5).
5. **`updateDraft()` wirft `DomainException` bei eingefrorener Version**: Smoke-Test 7 bestätigt das saubere Verhalten. Item-Sync ist destruktiv: IDs aus `$data['items']` werden aktualisiert, fehlende IDs neu angelegt, nicht mehr enthaltene Items gelöscht. `total_price` wird danach aus `SUM(items.total_price)` neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.
6. **`OfferTemplate::applyTo()` ist behutsam**: Überschreibt nur **leere** Text-Felder (`?: $template->default_xxx`); Items der Version werden hingegen komplett ersetzt (das ist der Sinn einer Vorlage). Property-Access-Warnungen für globale `now()`/`\Storage`-Facades haben wir mit `Carbon::now()` bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.
7. **Smoke-Test 9/9 grün**: Nummer generieren / atomares create / findByNumber / updateDraft mit Items (Summe 5.400 €) / Item-Sync (5.278 €) / markSent (+ Offer-Status auf sent) / updateDraft auf Sent wirft korrekt / createNewVersion (V1→superseded, V2 mit 3 kopierten Items, current_version umgeschoben, Offer zurück auf draft) / Template-anwenden (leere Texte übernommen, bestehender headline geschützt, 2 neue Items, Summe 2.119 €). Alles in einer Transaktion + Rollback.
### A4 — Lessons Learned (2026-04-18)
1. **`OfferService` mit DI-Konstruktor** (4 Repos injected). Kein Static-Helper wie `App\Services\Booking` — der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.
2. **2 Custom Exceptions, sprechend:**
- `OfferNotEditableException(OfferVersion $v, string $action)` — trägt `versionId` + `currentStatus` als readonly Properties, Controller können fein unterscheiden.
- `InvalidOfferStatusException(Offer $o, array $expected, string $action)` — trägt `offerId`, `currentStatus`, `expectedStatuses[]`.
3. **4 Domain-Events** (`OfferSent`, `OfferAccepted`, `OfferDeclined`, `OfferWithdrawn`) in `app/Events/Offer/`. Dispatchen per `Event::dispatch()` — die Mail/Audit-Log-Arbeit machen Listener in Ticket B6 und später Modul 3 Phase 4 (Communications). Der Service selbst weiß nichts von Mail oder `customer_mails`.
4. **`send()` isoliert den Token-Lifecycle**: Erzeugt frischen Access-Token in derselben Transaktion wie den Status-Wechsel. Falls später die Mail scheitert, wird der Event-Listener die Queue-Retry machen — der Status-Übergang bleibt gültig. Guard: nur auf der `current_version_id`-Version, nur im Status `draft`.
5. **`supersede()` revoked alte Tokens**: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Call `revoked_at IS NOT NULL` hat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.
6. **`markDeclined()` akzeptiert `sent` UND `accepted`**: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend auf `declined` gesetzt.
7. **`purgeExpired()` respektiert Offer-Hierarchie**: Setzt nur dann auch den Offer auf `expired`, wenn der Offer (a) noch `sent` ist und (b) die expirierte Version die `current_version_id` ist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).
8. **`convertToBooking()` nur als Guards+Stub**: Wirft sauber `InvalidOfferStatusException` (not accepted) und `DomainException` (double-invoke), aber der eigentliche Booking-Create hängt an `BookingService::createManual()` aus Modul 4 und wird in Ticket B8 finalisiert.
9. **Smoke-Test 13/13 grün**: createBlank + createBlank-mit-Template / updateVersion / send+Token / 2× Editable-Guard (updateVersion & send auf sent) / markAccepted + Double-Accept-Guard / supersede+Token-Revoke / withdraw + Double-Withdraw-Guard / convertToBooking-Guard / purgeExpired — alles in Transaktion + Rollback.
10. **Echte PHPUnit-Unit-Tests (Akzeptanzkriterium aus Spec) werden in Ticket F4 angelegt** (dort ist Coverage-Ziel ≥ 80 % für Service+Repo vereinbart). Der aktuelle Tinker-Smoke-Test liefert aber alle Pfad-Abdeckungen als Runbook, die F4 als Vorlage nutzen kann.
### A5 — Lessons Learned (2026-04-18)
1. **Erste `FormRequest`s im CRM-Projekt** (vorher keine `app/Http/Requests/`-Klasse). Muster: `Illuminate\Foundation\Http\FormRequest`, `authorize() =>` eingeloggter User, `rules()`, ggf. `withValidator` / `prepareForValidation`.
2. **StoreOfferRequest — Kreuz-Validierung**: Mindestens eines von `contact_id` / `inquiry_id` muss gesetzt sein (`withValidator` + Fehler auf `contact_id`). `template_id` mit `Rule::exists('offer_templates','id')->whereNull('deleted_at')` (keine gelöschte Vorlage).
3. **UpdateVersionRequest — WYSIWYG**: `intro_text` / `itinerary_text` / `closing_text` mit `strip_tags` + Whitelist; `headline` ohne Whitelist. Kein Mews/HtmlPurifier in `composer.json` — bei Bedarf nachziehen.
4. **Preise in Positionen**: `items.*.price_per_unit` = `numeric`; in `withValidator` für Typen außer `discount` Wert `>= 0` erzwingen.
5. **valid_until**`after_or_equal:today` (nicht in der Vergangenheit). **SendOfferRequest** `expires_at``after:now` (für E-Mail-Token echter Zeitpunkt in der Zukunft).
6. **cc/bcc** als langer String; Aufteilen in einzelne Adressen erst im Mail-Listener (B6).
7. **Datei-Upload-Regeln (MIME wie BookingFile)** verbleiben am Upload-Pfad (wie `FileRepository` bei Buchungen) — in A6 kann bei Bedarf ein `UploadOfferFileRequest` ergänzt werden.
### A6 — Lessons Learned (2026-04-24)
1. **Routen im bestehenden `admin`+`2fa`-Block** (nicht `auth.2fa` der Spec — im Projekt heißt es `2fa` + `AdminMiddleware`). Permission-Gates mit `auth.permission:…` in drei Gruppen: `offers-r` (lesen + DataTable + `GET offer/action` + PDF-Stub), `offers-w` (POST detail, Löschen, File-Upload/Delete), `offer-templates-w` (Vorlagen-CRUD).
2. **5 neue Keys in `config/permissions.php`:** `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w`. Bestehende User haben die Keys **nicht automatisch** in `users.permissions` — nach Deploy im User-Rechte-UI setzen (SuperAdmin) oder JSON per SQL ergänzen; bei `setPermissionsDefault()` greifen sie nur, wenn `permissions` leer/ungültig ist.
3. **Fein-Permissions in `OfferController@action`:** pro `action` werden `offers-w` (Mutation), `offers-send` (send/resend), `offers-accept` (Zusage/Absage/withdraw) geprüft; unbekannte Aktionen verlangen `offers-w`.
4. **Menü** `layout-sidenav` zwischen „Anfragen“ und „Kunden“: sichtbar bei `offers-r` **oder** `offers-w` (nur-Writer-Edge-Case), Icon `ion-md-document`, aktive Pfade `offers` / `offer/*`.
5. **Stub-UI:** `resources/views/offer/index.blade.php` mit server-side DataTable (`data_table_offers`); `detail` + Vorlagen-Views + PDF sind Platzhalter für B1/B3/B5/C1.
6. **Disk `offer`** war bereits in `config/filesystems.php``OfferFileController` setzt `disk=offer` wie beim Booking-Upload.
### A7 — Lessons Learned (2026-04-24)
1. **`ContactFactory` ergänzt:** `Offer` verlangt `contact_id`; ohne Factory müsste jeder Test-Kontakt manuell angelegt werden. `Contact` hatte bereits `HasFactory`, es fehlte nur die Klassendatei unter `database/factories/`.
2. **`OfferFactory` + V1 ohne Rekursion:** Erste `OfferVersion` wird in `OfferFactory::afterCreating()` per `OfferVersion::query()->create()` angelegt und `current_version_id` gesetzt — kein `OfferVersion::factory()` mit verschachteltem `Offer::factory()` (unique `(offer_id, version_no)` und zyklische FKs).
3. **`OfferVersionFactory`:** Standalone-`definition()` nutzt `offer_id``Offer::factory()` und **`version_no` = 2**, weil `Offer::factory()` automatisch V1 erzeugt. Für manuelle Tests: `forOffer($offer, $n)` mit frei wählbarer Versionsnummer.
4. **States:** `Offer::factory()->sent()` / `->accepted()` setzen Offer-Status und synchronisieren die **aktuelle** Version (`refresh()->currentVersion`). `OfferVersionFactory` hat zusätzlich `versionSent()` / `versionAccepted()` für reine Versions-Snapshots.
5. **`OfferItemFactory`:** Hilfsmethoden `forCurrentVersionOf(Offer)` und `forVersion(OfferVersion)`; `asDiscount()` für Rabattzeilen. Erste Position nutzt `OfferItem::TYPE_TRAVEL`.
6. **`OfferTemplateSeeder`:** Fünf realistische Vorlagen per `updateOrCreate(['name' => …])`; `created_by` = kleinster existierender `users.id` (ohne `User::factory()` im Seeder, damit lokale/Stage-Seeds keinen Blind-User erzeugen). Leere User-Tabelle → Seeder bricht ab mit Warnung. Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`.
7. **Smoke-Test:** An dieser Umgebung war kein MySQL-Hostname erreichbar (`getaddrinfo for global-mysql`); Syntax-Check `php -l` grün. Lokal/CI mit laufender DB: `Offer::factory()->create()` + `OfferItem::factory()->forCurrentVersionOf($o)`.
---
## 0. Übersicht
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **79 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
@ -375,12 +486,14 @@ Route::group(['middleware' => ['auth.2fa']], function () {
**Abhängigkeiten:** A2
**Dateien (neu):**
- `database/factories/ContactFactory.php` (FK `offers.contact_id`)
- `database/factories/OfferFactory.php`
- `database/factories/OfferVersionFactory.php`
- `database/factories/OfferItemFactory.php`
- `database/factories/OfferTemplateFactory.php`
- `database/seeds/OfferTemplateSeeder.php` (Namespace `Database\Seeders`, vgl. `composer.json` PSR-4 → `database/seeds/`)
Factories decken alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an.
Factories decken zentrale Stati ab (u. a. `Offer::factory()->sent()` / `->accepted()`, plus Version-States in `OfferVersionFactory`). Seeder legt 5 Test-Templates in `offer_templates` an.
---