mein-sterntours/dev/offers/umsetzung.md

1055 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Modul 6 — Angebote: Implementierungs-Tickets
**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. ✅ 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.
| Phase | Inhalt | Tickets | Aufwand |
|-------|--------|---------|---------|
| A | Fundament (DB / Models / Repositories / Services / Routing / Permissions) | A1A7 | ~10 Tage |
| B | Admin-UI (Mitarbeiter-Backend) | B1B9 | ~18 Tage |
| C | Vorlagen (Offer-Templates) | C1C3 | ~3 Tage |
| D | Kundenseitiger Freigabe-Link (öffentliches Frontend) | D1D5 | ~5 Tage |
| E | Versionierung & Archivierung | E1E3 | ~3 Tage |
| F | Polish, Automatik, Tests | F1F6 | ~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/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 zentrale Stati ab (u. a. `Offer::factory()->sent()` / `->accepted()`, plus Version-States in `OfferVersionFactory`). Seeder legt 5 Test-Templates in `offer_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 | A1A7 | Fundament steht, leere Offer-Liste im Backend erreichbar |
| S2 | B1B3 | Angebot anlegen & bearbeiten (ohne Versand) |
| S3 | B4B6 | Dokumente + PDF + Versand funktionieren |
| S4 | B7B9, E1, E2 | Annahme/Ablehnung, Versionierung |
| S5 | C1C3, D1D5 | Vorlagen-Verwaltung + Kundenseite (Parallel möglich) |
| S6 | E3, F1F6, 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.