1055 lines
57 KiB
Markdown
1055 lines
57 KiB
Markdown
# 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 1–2 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: **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/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 | 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.
|