# Modul 6 — Angebote: Implementierungs-Tickets **Status:** Entwurf, bereit zur Umsetzung **Erstellt:** April 2026 **Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md) **Abhängigkeiten-Check vor Start:** - Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren. - Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`. - Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen. - Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar. --- ## 0. Übersicht Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **7–9 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5. | Phase | Inhalt | Tickets | Aufwand | |-------|--------|---------|---------| | A | Fundament (DB / Models / Repositories / Services / Routing / Permissions) | A1–A7 | ~10 Tage | | B | Admin-UI (Mitarbeiter-Backend) | B1–B9 | ~18 Tage | | C | Vorlagen (Offer-Templates) | C1–C3 | ~3 Tage | | D | Kundenseitiger Freigabe-Link (öffentliches Frontend) | D1–D5 | ~5 Tage | | E | Versionierung & Archivierung | E1–E3 | ~3 Tage | | F | Polish, Automatik, Tests | F1–F6 | ~5 Tage | Nach Abschluss von Phase B5 kann D parallel zu C + E gebaut werden. --- ## 1. Reihenfolge und kritischer Pfad ``` A1 → A2 → A3 → A4 → A5 → A6 → A7 ─────────┐ │ ┌─── B1 → B2 → B3 → B4 → B5 ──────┼──► B6 → B7 ─┐ │ │ │ │ ┌── C1 → C2 ─┤ ├─► B8 → B9 │ │ │ │ │ ├── D1 → D2 → D3 → D4 → D5─┤ │ │ │ │ └── E1 → E2 → E3 ──────────┘ │ └──► F1 … F6 (laufend) ``` --- ## Phase A — Fundament ### Ticket A1 — Datenbank-Migrationen anlegen **Typ:** Migration **Aufwand:** 1 Tag **Abhängigkeiten:** Modul 3 Phase 2 (`contacts`, `inquiries`) **Dateien (neu):** - `database/migrations/2026_05_01_100001_create_offers_table.php` - `database/migrations/2026_05_01_100002_create_offer_versions_table.php` - `database/migrations/2026_05_01_100003_create_offer_items_table.php` - `database/migrations/2026_05_01_100004_create_offer_templates_table.php` - `database/migrations/2026_05_01_100005_create_offer_files_table.php` - `database/migrations/2026_05_01_100006_create_offer_access_tokens_table.php` - `database/migrations/2026_05_01_100007_add_offer_id_to_bookings_table.php` **Schema `offers`:** ```php Schema::create('offers', function (Blueprint $t) { $t->id(); $t->string('offer_number', 32)->unique(); // z. B. "2026-00123" $t->foreignId('contact_id')->constrained('contacts')->restrictOnDelete(); $t->foreignId('inquiry_id')->nullable()->constrained('inquiries')->nullOnDelete(); $t->foreignId('booking_id')->nullable()->constrained('bookings')->nullOnDelete(); $t->enum('status', ['draft', 'sent', 'accepted', 'declined', 'expired', 'withdrawn']) ->default('draft'); $t->unsignedBigInteger('current_version_id')->nullable(); // FK wird nachträglich gesetzt $t->foreignId('created_by')->constrained('users'); $t->timestamps(); $t->softDeletes(); $t->index(['status', 'contact_id']); $t->index('inquiry_id'); }); ``` **Schema `offer_versions`:** ```php Schema::create('offer_versions', function (Blueprint $t) { $t->id(); $t->foreignId('offer_id')->constrained('offers')->cascadeOnDelete(); $t->unsignedInteger('version_no'); $t->enum('status', ['draft', 'sent', 'accepted', 'declined', 'expired', 'superseded']) ->default('draft'); $t->date('valid_until')->nullable(); $t->decimal('total_price', 10, 2)->default(0); $t->string('headline')->nullable(); $t->text('intro_text')->nullable(); $t->longText('itinerary_text')->nullable(); $t->text('closing_text')->nullable(); $t->foreignId('template_id')->nullable()->constrained('offer_templates')->nullOnDelete(); $t->string('pdf_path')->nullable(); $t->boolean('pdf_archived')->default(false); $t->dateTime('sent_at')->nullable(); $t->dateTime('accepted_at')->nullable(); $t->enum('accepted_via', ['customer_link', 'admin', 'email'])->nullable(); $t->foreignId('created_by')->constrained('users'); $t->timestamps(); $t->unique(['offer_id', 'version_no']); }); ``` Danach nachträglicher FK auf `offers.current_version_id → offer_versions.id`. **Schema `offer_items`:** ```php Schema::create('offer_items', function (Blueprint $t) { $t->id(); $t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete(); $t->unsignedInteger('position')->default(0); $t->enum('type', ['travel','service','option','discount','insurance','custom']); $t->string('title'); $t->text('description')->nullable(); $t->unsignedInteger('quantity')->default(1); $t->decimal('price_per_unit', 10, 2)->default(0); $t->decimal('total_price', 10, 2)->default(0); $t->foreignId('travel_program_id')->nullable(); // kein FK solange v2 nicht migriert $t->foreignId('fewo_lodging_id')->nullable(); $t->json('metadata')->nullable(); $t->timestamps(); $t->index(['offer_version_id', 'position']); }); ``` **Schema `offer_templates`:** ```php Schema::create('offer_templates', function (Blueprint $t) { $t->id(); $t->string('name'); $t->string('description')->nullable(); $t->string('organization')->nullable(); // "Reise-Organisation", aus Briefing $t->string('headline')->nullable(); $t->text('intro_text')->nullable(); $t->longText('itinerary_text')->nullable(); $t->text('closing_text')->nullable(); $t->json('items_json')->nullable(); // serialisierte Default-Positionen $t->json('default_document_ids')->nullable(); // IDs der zentral hinterlegten Dokumente $t->boolean('active')->default(true); $t->foreignId('created_by')->constrained('users'); $t->timestamps(); $t->softDeletes(); }); ``` **Schema `offer_files`** (bewusst analog zu `booking_files`, später Migration in `attachments`): ```php Schema::create('offer_files', function (Blueprint $t) { $t->id(); $t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete(); $t->string('identifier')->nullable(); // z. B. "letterhead" für feste Typen $t->string('disk')->default('offers'); $t->string('dir'); $t->string('filename'); $t->string('original_name'); $t->string('ext', 10); $t->string('mine', 100); // Schreibweise übernommen aus BookingFile $t->unsignedBigInteger('size'); $t->boolean('frozen')->default(false); // true ab Versand, um Anhang „einzufrieren" $t->timestamps(); }); ``` **Schema `offer_access_tokens`:** ```php Schema::create('offer_access_tokens', function (Blueprint $t) { $t->id(); $t->foreignId('offer_version_id')->constrained('offer_versions')->cascadeOnDelete(); $t->string('token', 64)->unique(); $t->dateTime('expires_at')->nullable(); $t->dateTime('opened_at')->nullable(); $t->dateTime('accepted_at')->nullable(); $t->dateTime('declined_at')->nullable(); $t->string('ip_address', 45)->nullable(); $t->string('user_agent', 255)->nullable(); $t->timestamps(); }); ``` **`add_offer_id_to_bookings_table`:** Spalte `bookings.offer_id` als nullable FK (Rückverweis bei Konvertierung). **Akzeptanzkriterien:** - [ ] Alle 7 Migrationen laufen sauber durch (`artisan migrate`) und `migrate:rollback` invertiert sie. - [ ] FKs sind konsistent; kein Drop einer Tabelle bricht andere. - [ ] Mit `migrate:fresh --seed` reproduzierbar. **Risiken:** - `travel_program_id` / `fewo_lodging_id` haben aktuell keine Laravel-Tabelle (v2). FK vorerst nicht setzen, Werte nur als Referenz speichern. Ticket beim v2-Modul: FK nachreichen. --- ### Ticket A2 — Eloquent-Models **Typ:** Code **Aufwand:** 1 Tag **Abhängigkeiten:** A1 **Dateien (neu):** - `app/Models/Offer.php` - `app/Models/OfferVersion.php` - `app/Models/OfferItem.php` - `app/Models/OfferTemplate.php` - `app/Models/OfferFile.php` - `app/Models/OfferAccessToken.php` **`Offer.php`:** - `$fillable`, `$casts` (status als Enum, Timestamps). - Relationen: `contact()`, `inquiry()`, `booking()`, `versions()`, `currentVersion()`, `createdBy()`. - Scopes: `scopeOpen()`, `scopeForContact($q, $id)`, `scopeWithStatus($q, array $stati)`. - Factories: `HasFactory`. **`OfferVersion.php`:** - Relationen: `offer()`, `items()`, `files()`, `tokens()`, `template()`, `createdBy()`. - Helper: `isEditable()` (true wenn status=draft), `latestToken()`, `totalPriceFormatted()`. - Dateipfad-Helper: `getPdfUrl()` analog zu `BookingFile::getURL()`. **`OfferFile.php`:** - Gleiche Methoden wie `BookingFile::getURL()`, `::getIconExt()`, `::formatBytes()` — Interface-kompatibel, damit derselbe Frontend-Code verwendbar bleibt. **`OfferAccessToken.php`:** - `::generate(OfferVersion $v, ?Carbon $expiresAt = null): self` — erzeugt kryptografisch sicheren Token (`Str::random(64)`), speichert, gibt zurück. - `scopeActive()` — nicht expired, nicht declined. **Akzeptanzkriterien:** - [ ] `Offer::factory()->create()` funktioniert. - [ ] `$offer->currentVersion->items` liefert korrekte Collection. - [ ] `OfferAccessToken::generate()` erzeugt unique Token, Kollisionsschutz per `unique`-Constraint. --- ### Ticket A3 — Repositories **Typ:** Code **Aufwand:** 1 Tag **Abhängigkeiten:** A2 **Dateien (neu):** - `app/Repositories/OfferRepository.php` extends `BaseRepository` - `app/Repositories/OfferVersionRepository.php` extends `BaseRepository` - `app/Repositories/OfferFileRepository.php` extends `FileRepository` (analog `BookingFileRepository`) - `app/Repositories/OfferTemplateRepository.php` extends `BaseRepository` **`OfferRepository`:** - `create(array $data, int $contactId, ?int $inquiryId = null): Offer` — legt `Offer` + `OfferVersion#1` (status=draft) an, setzt `current_version_id`. - `generateOfferNumber(): string` — Format `{YYYY}-{5-stellig laufend}`, nutzt `DB::transaction` mit `SELECT … FOR UPDATE` auf einer Zähler-Tabelle ODER nimmt `MAX(offer_number)` mit Transaktion. - `findByNumber(string $no): ?Offer` **`OfferVersionRepository`:** - `updateDraft(OfferVersion $v, array $data): OfferVersion` — nur wenn `status=draft`; Items werden aus `$data['items']` synchronisiert. - `createNewVersion(Offer $offer): OfferVersion` — dupliziert aktuelle Version inkl. items + files (als neue Zeilen), `version_no = max+1`, vorherige wird `superseded`, `offer.current_version_id` wird aktualisiert. Atomar in Transaktion. - `markSent(OfferVersion $v): OfferVersion` — setzt status, `sent_at`, `frozen` auf allen `offer_files`. **`OfferFileRepository` (analog `BookingFileRepository`):** - `__construct(OfferFile $model)` - `save()` schreibt mit `$this->offer_version_id`, `$this->identifier`, `$this->dir = /files/YYYY/MM/`. - `response()` identische JSON-Struktur wie `BookingFileRepository::response()`, damit Frontend-Code geteilt werden kann. **Akzeptanzkriterien:** - [ ] `OfferRepository::create()` erzeugt Offer + initiale Version atomar; Rollback bei Fehler. - [ ] `generateOfferNumber()` ist race-safe (parallele Tests erzeugen keine Duplikate). - [ ] `createNewVersion()` kopiert Items + Files, Preise bleiben gleich, alte Version `superseded`. --- ### Ticket A4 — OfferService (Business-Logik) **Typ:** Code **Aufwand:** 2 Tage **Abhängigkeiten:** A3 **Dateien (neu):** - `app/Services/OfferService.php` **Methoden:** ```php public function createFromInquiry(int $inquiryId, ?int $templateId = null): Offer; public function createBlank(int $contactId, ?int $templateId = null): Offer; public function applyTemplate(OfferVersion $v, OfferTemplate $template): OfferVersion; public function updateVersion(OfferVersion $v, array $data): OfferVersion; public function send(OfferVersion $v, array $mailData): void; // erzeugt Token, versendet Mail, wechselt Status public function markAccepted(OfferVersion $v, string $via, ?string $ip = null, ?string $ua = null): void; public function markDeclined(OfferVersion $v, string $via, ?string $reason = null): void; public function supersede(OfferVersion $v): OfferVersion; // erzeugt neue Version public function withdraw(Offer $offer, string $reason): void; public function convertToBooking(Offer $offer): Booking; // delegiert an BookingService::createManual() ``` **Logikregeln:** - `send()` nur möglich, wenn Version `status=draft` und `current_version` der Offer ist. - `markAccepted()` nur möglich bei `status=sent`; Offer.status wechselt ebenfalls auf `accepted`; ältere noch aktive Versionen werden `superseded`. - `supersede()` deaktiviert bestehenden Token, erzeugt aber beim nächsten `send()` einen neuen. - `convertToBooking()` nur bei Offer.status=accepted; nutzt `BookingService::createManual($offer->currentVersion->toBookingData())`; setzt `offer.booking_id`, kopiert `offer_files` als `booking_files`. **Akzeptanzkriterien:** - [ ] Unit-Tests für jede öffentliche Methode (happy path + Statusguard-Fehler). - [ ] `convertToBooking()` arbeitet idempotent — zweiter Aufruf wirft DomainException. - [ ] Service wirft sprechende, eigene Exceptions (`OfferNotEditableException`, `InvalidOfferStatusException`). --- ### Ticket A5 — FormRequests **Typ:** Code **Aufwand:** 0.5 Tage **Abhängigkeiten:** A3 **Dateien (neu):** - `app/Http/Requests/Offer/StoreOfferRequest.php` - `app/Http/Requests/Offer/UpdateVersionRequest.php` - `app/Http/Requests/Offer/SendOfferRequest.php` - `app/Http/Requests/Offer/OfferTemplateRequest.php` Validierungen pro Feld (Preise ≥ 0, `valid_until` in Zukunft, HTML-Sanitizing für `itinerary_text`, Dateiuploads MIME-Typen wie bei `BookingFile`). --- ### Ticket A6 — Routing + Permissions **Typ:** Code **Aufwand:** 0.5 Tage **Abhängigkeiten:** A4 **Dateien (geändert):** - `routes/web.php` — neuer Block analog zu `bookings`/`leads`: ```php Route::group(['middleware' => ['auth.2fa']], function () { Route::get('data/table/offers', 'OfferController@getOffers')->name('data_table_offers'); Route::get('/offers/{step?}', 'OfferController@index')->name('offers'); Route::get('/offer/detail/{id}', 'OfferController@detail')->name('offer_detail'); Route::post('/offer/detail/{id}', 'OfferController@store')->name('offer_detail_store'); Route::post('/offer/modal/load', 'OfferController@loadModal')->name('offer_modal_load'); Route::get('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action'); Route::post('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action_store'); Route::get('/offer/delete/{id}/{del?}', 'OfferController@delete')->name('offer_delete'); Route::get('/offer/pdf/{versionId}', 'OfferController@pdf')->name('offer_pdf'); // Templates Route::get('/offer-templates', 'OfferTemplateController@index')->name('offer_templates'); Route::get('/offer-template/detail/{id}', 'OfferTemplateController@detail')->name('offer_template_detail'); Route::post('/offer-template/detail/{id?}', 'OfferTemplateController@store')->name('offer_template_detail_store'); Route::get('/offer-template/delete/{id}', 'OfferTemplateController@delete')->name('offer_template_delete'); // Files (analog zu BookingController action-upload-booking-file) Route::post('/offer/upload/{versionId}', 'OfferFileController@upload')->name('offer_file_upload'); Route::get('/offer/file/delete/{id}', 'OfferFileController@delete')->name('offer_file_delete'); }); ``` **Permissions** (in Seeder `database/seeds/PermissionSeeder.php` oder via Config): - `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w` **Menü-Eintrag** in `resources/views/layouts/_sidenav.blade.php` (analog zu bestehendem „Anfragen" und „Buchungen"). **Akzeptanzkriterien:** - [ ] Routen erreichbar nur mit Permission. - [ ] Menüpunkt „Angebote" erscheint zwischen „Anfragen" und „Buchungen". --- ### Ticket A7 — Factories + Seeder (für Tests) **Typ:** Code **Aufwand:** 0.5 Tage **Abhängigkeiten:** A2 **Dateien (neu):** - `database/factories/OfferFactory.php` - `database/factories/OfferVersionFactory.php` - `database/factories/OfferItemFactory.php` - `database/factories/OfferTemplateFactory.php` Factories decken alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an. --- ## Phase B — Admin-UI ### Ticket B1 — Offer-Liste `/offers` **Typ:** UI + Controller **Aufwand:** 2 Tage **Abhängigkeiten:** A6 **Dateien (neu):** - `app/Http/Controllers/OfferController.php` — Methode `index()`, `getOffers()`. - `resources/views/offer/index.blade.php` - `resources/views/offer/_list_row.blade.php` (optional, wenn DataTable serverseitig gerendert) **Ablauf:** - DataTable (Server-side via `DataTableController`-Pattern, siehe `data/table/bookings`-Route). - Spalten: Angebots-Nr. | Kontakt | Reise/Organisation | Version | Status-Badge | Gültig bis | Gesamtpreis | Mitarbeiter | Erstellt am | Aktionen. - Filter: Status (Multi-Select), Mitarbeiter, Datumsrange (erstellt/gültig bis), Volltext (Kontakt/Nummer). - Status-Badges (wiederverwendbar via ``): - `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.