mein-sterntours/dev/offers/umsetzung.md

57 KiB
Raw Blame History

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 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.idbigint (signed!)
    • users.idint unsigned

    Fix in 3 Migrationen (100001, 100002, 100004): explizit $t->bigInteger('contact_id') etc. + manueller $t->foreign(...)->references('id')->on('xxx'). Für usersunsignedInteger('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:

    mkdir -p storage/migrations-tmp/offers
    for f in database/migrations/2026_04_17_*.php; do
      ln -sf "$(realpath $f)" "storage/migrations-tmp/offers/$(basename $f)"
    done
    php artisan migrate --path=storage/migrations-tmp/offers --realpath --force
    

    So werden ausschließlich die 7 Offer-Files geladen.

  3. Class-Name-Bug entdeckt & gefixt: database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php deklarierte fälschlicherweise class CreateBookingVoucherTable (richtig: CreateBookingVoucherAgencyTable). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen.

  4. Verifikation OK: 15 neue FKs gesetzt (5 auf offers, 3 auf offer_versions, je 12 auf andere + 1 Reverse auf booking.offer_id), Indizes/Unique-Constraints alle vorhanden. Siehe SHOW CREATE TABLE-Outputs in den Smoke-Tests.

  5. Akzeptanzkriterium „migrate:fresh --seed reproduzierbar": Auf Test nicht ausführbar (würde DB löschen). Empfehlung: in CI auf einer Throwaway-DB testen, sobald CI-Pipeline existiert.

Live-Deploy von A1 (TODO später)

Wenn A2-A7 fertig sind und das Modul deploybar ist, müssen für Live mit:

  • 7 Migrationen aus database/migrations/2026_04_17_100001 bis 100007
  • 1 Klassennamen-Fix aus database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php

ein eigenes Live-Deploy-Handbuch geschrieben werden (analog phase-2-live-deploy.md).

A2 — Lessons Learned (2026-04-18)

  1. 6 Models angelegt: Offer, OfferVersion, OfferItem, OfferTemplate, OfferFile, OfferAccessToken in app/Models/. Alle mit HasFactory, $connection='mysql', typisierten PHPDoc-Annotations, STATUS_*-Konstanten (statt String-Literals) und sauberen BelongsTo/HasMany-Relations.
  2. Status-Konstanten: Stati sind als self::STATUS_DRAFT etc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl. superseded). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet und whereIn mit Strings gut funktioniert.
  3. OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self erzeugt Str::random(64)-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Property plain_token. Counterpart findActiveByPlainToken(string) hasht den eingehenden Klartext und scoped auf active() (nicht widerrufen, nicht abgelaufen).
  4. OfferFile API-kompatibel zu BookingFile: Gleiche Method-Signaturen (getURL, getPath, getIconExt, formatBytes) — so können Blade-Partials/Dropzone-Helper aus dem Booking-Modul geteilt werden. Unterschied: Spalte include_in_pdf statt frozen (semantisch klarer).
  5. Smoke-Test (Tinker, in Transaktion + Rollback): Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading currentVersion.items / .tokens / contact grün, totalPriceFormatted() → "1.234,56 €", isEditable() → true für Draft.
  6. Offer-Inquiry-Relation zeigt auf Lead-Model: Die Tabelle heißt nach Phase 2 inquiries, das Eloquent-Model ist aber weiterhin App\Models\Lead (Umbenennung des Models ist eigenes Ticket). Offer verwendet $this->belongsTo(Lead::class, 'inquiry_id').

A3 — Lessons Learned (2026-04-18)

  1. 4 Repositories angelegt: OfferRepository, OfferVersionRepository, OfferTemplateRepository (extends BaseRepository) und OfferFileRepository (extends FileRepository). Alle mit use Illuminate\Support\Facades\DB; — konsistent mit Phase-1-Stil der ContactsMergeDuplicates-Command.
  2. generateOfferNumber() race-safe umgesetzt: lockForUpdate() auf der höchsten offer_number LIKE '{year}-%'-Zeile innerhalb einer DB::transaction; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordert YYYY-NNNNN.
  3. OfferRepository::create() atomar: Offer + Version#1 in einem DB::transaction-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.
  4. createNewVersion() dupliziert via replicate(): Methode nutzt Eloquents replicate(['except-cols']), um Items + Files als neue Zeilen zu kopieren. Physische Datei-Binaries werden nicht dupliziert — die Datei-Zeile bekommt nur eine neue DB-ID. Falls später einzelne Versionen gelöscht werden, muss die Delete-Logik prüfen, ob andere Versionen noch auf dieselbe Datei referenzieren (Aufgabe für B4/B5).
  5. updateDraft() wirft DomainException bei eingefrorener Version: Smoke-Test 7 bestätigt das saubere Verhalten. Item-Sync ist destruktiv: IDs aus $data['items'] werden aktualisiert, fehlende IDs neu angelegt, nicht mehr enthaltene Items gelöscht. total_price wird danach aus SUM(items.total_price) neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.
  6. OfferTemplate::applyTo() ist behutsam: Überschreibt nur leere Text-Felder (?: $template->default_xxx); Items der Version werden hingegen komplett ersetzt (das ist der Sinn einer Vorlage). Property-Access-Warnungen für globale now()/\Storage-Facades haben wir mit Carbon::now() bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.
  7. Smoke-Test 9/9 grün: Nummer generieren / atomares create / findByNumber / updateDraft mit Items (Summe 5.400 €) / Item-Sync (5.278 €) / markSent (+ Offer-Status auf sent) / updateDraft auf Sent wirft korrekt / createNewVersion (V1→superseded, V2 mit 3 kopierten Items, current_version umgeschoben, Offer zurück auf draft) / Template-anwenden (leere Texte übernommen, bestehender headline geschützt, 2 neue Items, Summe 2.119 €). Alles in einer Transaktion + Rollback.

A4 — Lessons Learned (2026-04-18)

  1. OfferService mit DI-Konstruktor (4 Repos injected). Kein Static-Helper wie App\Services\Booking — der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.
  2. 2 Custom Exceptions, sprechend:
    • OfferNotEditableException(OfferVersion $v, string $action) — trägt versionId + currentStatus als readonly Properties, Controller können fein unterscheiden.
    • InvalidOfferStatusException(Offer $o, array $expected, string $action) — trägt offerId, currentStatus, expectedStatuses[].
  3. 4 Domain-Events (OfferSent, OfferAccepted, OfferDeclined, OfferWithdrawn) in app/Events/Offer/. Dispatchen per Event::dispatch() — die Mail/Audit-Log-Arbeit machen Listener in Ticket B6 und später Modul 3 Phase 4 (Communications). Der Service selbst weiß nichts von Mail oder customer_mails.
  4. send() isoliert den Token-Lifecycle: Erzeugt frischen Access-Token in derselben Transaktion wie den Status-Wechsel. Falls später die Mail scheitert, wird der Event-Listener die Queue-Retry machen — der Status-Übergang bleibt gültig. Guard: nur auf der current_version_id-Version, nur im Status draft.
  5. supersede() revoked alte Tokens: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Call revoked_at IS NOT NULL hat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.
  6. markDeclined() akzeptiert sent UND accepted: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend auf declined gesetzt.
  7. purgeExpired() respektiert Offer-Hierarchie: Setzt nur dann auch den Offer auf expired, wenn der Offer (a) noch sent ist und (b) die expirierte Version die current_version_id ist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).
  8. convertToBooking() nur als Guards+Stub: Wirft sauber InvalidOfferStatusException (not accepted) und DomainException (double-invoke), aber der eigentliche Booking-Create hängt an BookingService::createManual() aus Modul 4 und wird in Ticket B8 finalisiert.
  9. Smoke-Test 13/13 grün: createBlank + createBlank-mit-Template / updateVersion / send+Token / 2× Editable-Guard (updateVersion & send auf sent) / markAccepted + Double-Accept-Guard / supersede+Token-Revoke / withdraw + Double-Withdraw-Guard / convertToBooking-Guard / purgeExpired — alles in Transaktion + Rollback.
  10. Echte PHPUnit-Unit-Tests (Akzeptanzkriterium aus Spec) werden in Ticket F4 angelegt (dort ist Coverage-Ziel ≥ 80 % für Service+Repo vereinbart). Der aktuelle Tinker-Smoke-Test liefert aber alle Pfad-Abdeckungen als Runbook, die F4 als Vorlage nutzen kann.

A5 — Lessons Learned (2026-04-18)

  1. Erste FormRequests 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_untilafter_or_equal:today (nicht in der Vergangenheit). SendOfferRequest expires_atafter: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.phpOfferFileController 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_idOffer::factory() und version_no = 2, weil Offer::factory() automatisch V1 erzeugt. Für manuelle Tests: forOffer($offer, $n) mit frei wählbarer Versionsnummer.
  4. States: Offer::factory()->sent() / ->accepted() setzen Offer-Status und synchronisieren die aktuelle Version (refresh()->currentVersion). OfferVersionFactory hat zusätzlich versionSent() / versionAccepted() für reine Versions-Snapshots.
  5. OfferItemFactory: Hilfsmethoden forCurrentVersionOf(Offer) und forVersion(OfferVersion); asDiscount() für Rabattzeilen. Erste Position nutzt OfferItem::TYPE_TRAVEL.
  6. OfferTemplateSeeder: Fünf realistische Vorlagen per updateOrCreate(['name' => …]); created_by = kleinster existierender users.id (ohne User::factory() im Seeder, damit lokale/Stage-Seeds keinen Blind-User erzeugen). Leere User-Tabelle → Seeder bricht ab mit Warnung. Ausführung: php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder.
  7. Smoke-Test: An dieser Umgebung war kein MySQL-Hostname erreichbar (getaddrinfo for global-mysql); Syntax-Check php -l grün. Lokal/CI mit laufender DB: Offer::factory()->create() + OfferItem::factory()->forCurrentVersionOf($o).

0. Übersicht

Insgesamt 6 Phasen mit 32 Tickets. Geschätzter Gesamtaufwand: 79 Wochen (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.

Phase Inhalt Tickets Aufwand
A Fundament (DB / Models / Repositories / Services / Routing / Permissions) A1A7 ~10 Tage
B Admin-UI (Mitarbeiter-Backend) B1B9 ~18 Tage
C Vorlagen (Offer-Templates) C1C3 ~3 Tage
D Kundenseitiger Freigabe-Link (öffentliches Frontend) D1D5 ~5 Tage
E Versionierung & Archivierung E1E3 ~3 Tage
F Polish, Automatik, Tests F1F6 ~5 Tage

Nach Abschluss von Phase B5 kann D parallel zu C + E gebaut werden.


1. Reihenfolge und kritischer Pfad

A1 → A2 → A3 → A4 → A5 → A6 → A7 ─────────┐
                                          │
        ┌─── B1 → B2 → B3 → B4 → B5 ──────┼──► B6 → B7 ─┐
        │                                 │             │
        │                    ┌── C1 → C2 ─┤             ├─► B8 → B9
        │                    │            │             │
        │                    ├── D1 → D2 → D3 → D4 → D5─┤
        │                    │                          │
        │                    └── E1 → E2 → E3 ──────────┘
        │
        └──► F1 … F6 (laufend)

Phase A — Fundament

Ticket A1 — Datenbank-Migrationen anlegen

Typ: Migration Aufwand: 1 Tag Abhängigkeiten: Modul 3 Phase 2 (contacts, inquiries)

Dateien (neu):

  • database/migrations/2026_05_01_100001_create_offers_table.php
  • database/migrations/2026_05_01_100002_create_offer_versions_table.php
  • database/migrations/2026_05_01_100003_create_offer_items_table.php
  • database/migrations/2026_05_01_100004_create_offer_templates_table.php
  • database/migrations/2026_05_01_100005_create_offer_files_table.php
  • database/migrations/2026_05_01_100006_create_offer_access_tokens_table.php
  • database/migrations/2026_05_01_100007_add_offer_id_to_bookings_table.php

Schema offers:

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:

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:

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:

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):

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:

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:

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:
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_filesattachments-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.phpconvertToBooking() 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.

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:
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 OfferVersions mit:
    • Version-Nr., Status-Badge, Erstellt am, Versendet am, Summe, PDF-Link.
    • Link „Diese Version ansehen" (read-only).

Ticket E3 — PDF-Archivierung Cron

Typ: Code Aufwand: 1 Tag Abhängigkeiten: B5

Dateien (neu):

  • app/Console/Commands/OfferArchiveOldPdfs.php

Logik:

  • Läuft nächtlich via Console\Kernel::schedule().
  • Für jede Offer: PDFs aller Versionen außer der aktuellen + der zuletzt akzeptierten werden auf kalten Storage (disk('offers-cold')) verschoben, pdf_archived=true.
  • Konfigurierbar via config/offers.php (Retention-Limit, Disk-Name).

Akzeptanzkriterien:

  • PDF-Abruf (Ticket B5 Route) generiert das PDF bei Bedarf neu, wenn pdf_archived=true und Datei nicht auf Hot-Disk.
  • Konfiguration über config/offers.php steuerbar.

Phase F — Polish, Automatik, Tests

Ticket F1 — Zähler/Badges in Contacts und Inquiries

Typ: UI + Code Aufwand: 0.5 Tage Abhängigkeiten: B1

  • In resources/views/contact/index.blade.php neue Spalte „Angebote" mit Zähler-Badge (analog Anfragen/Buchungen aus Phase 1 Contacts).
  • Klick öffnet History-Modal (analog _detail_history.blade.php, Scope auf Offers).
  • Dito in resources/views/lead/detail.blade.php / inquiry/detail.blade.php: neue Sektion „Angebote zu dieser Anfrage".

Ticket F2 — Ablauf-Automatik (expired-Cron)

Typ: Code Aufwand: 0.5 Tage Abhängigkeiten: A4

Dateien (neu):

  • app/Console/Commands/OfferExpireVersions.php

Logik:

  • Täglich um 03:00: setzt alle OfferVersion mit valid_until < today und status=sent auf expired.
  • Wenn Offer keine aktiven Versionen mehr hat → Offer.status=expired.
  • Optional: Erinnerung 3 Tage vor Ablauf an zuständigen Mitarbeiter (Feature-Flag).

Ticket F3 — Communications-Integration

Typ: Code Aufwand: 1 Tag Abhängigkeiten: Modul 3 Phase 4 (nach Abschluss), B6, D4

  • Offer-bezogene E-Mails werden mit offer_id (und offer_version_id) in communications hinterlegt.
  • Detail-View Ticket B3 bekommt eigenen Tab „E-Mails" mit den zugehörigen Nachrichten.
  • Wenn Kunde über Freigabe-Link antwortet (D4): Notification landet ebenfalls im gleichen Verlauf.

Ticket F4 — Feature-Tests

Typ: Tests Aufwand: 2 Tage Abhängigkeiten: alle

Dateien (neu):

  • tests/Feature/Offer/OfferCrudTest.php
  • tests/Feature/Offer/OfferWorkflowTest.php (draft → sent → accepted → converted)
  • tests/Feature/Offer/OfferVersioningTest.php (supersede, history)
  • tests/Feature/Offer/OfferPublicAccessTest.php (Token-Flow)
  • tests/Feature/Offer/OfferPdfTest.php (PDF wird erzeugt, enthält Angebots-Nr.)
  • tests/Unit/Services/OfferServiceTest.php

Coverage-Ziel: ≥ 80 % für OfferService + OfferRepository.

Ticket F5 — Dokumentation

Typ: Doku Aufwand: 0.5 Tage Abhängigkeiten: alle

  • dev/offers/README.md mit Funktionsübersicht.
  • dev/offers/user-guide.md — Anleitung für Mitarbeiter (mit Screenshots).
  • Eintrag in CLAUDE.md / mein.sterntours.de/CLAUDE.md für künftige AI-Assistenz.

Ticket F6 — Permissions-Review + Seeder-Update

Typ: Code Aufwand: 0.5 Tage Abhängigkeiten: A6

  • Seeder legt Permissions offers-r, offers-w, offers-send, offers-accept, offer-templates-w an.
  • Bestehende Rollen (Admin, Mitarbeiter, Buchhaltung) werden mit sinnvoller Default-Zuweisung versehen.

2. Umsetzungs-Reihenfolge (Sprint-Vorschlag, 2-Wochen-Sprints)

Sprint Tickets Ziel
S1 A1A7 Fundament steht, leere Offer-Liste im Backend erreichbar
S2 B1B3 Angebot anlegen & bearbeiten (ohne Versand)
S3 B4B6 Dokumente + PDF + Versand funktionieren
S4 B7B9, E1, E2 Annahme/Ablehnung, Versionierung
S5 C1C3, D1D5 Vorlagen-Verwaltung + Kundenseite (Parallel möglich)
S6 E3, F1F6, B8 Archivierung, Integration in Contacts/Inquiries, Angebot→Buchung, Tests, Doku

Meilenstein „MVP verwendbar" nach S3: Mitarbeiter können ein Angebot anlegen, PDF erzeugen und versenden — Annahme zunächst nur per Admin-Statuswechsel.

Meilenstein „Vollversion" nach S6: Inklusive Kundenportal-Link, Versionierung, Archivierung, Konvertierung in Buchung, Tests.


3. Risiken und Gegenmaßnahmen

# Risiko Wahrscheinlichkeit Wirkung Gegenmaßnahme
R1 BookingService::createManual() aus Modul 4 noch nicht fertig, wenn B8 dran ist Mittel B8 blockiert Mocking in S4, echte Integration in S6
R2 PDF-Generierung performance-kritisch bei vielen Anhängen (PDFMerger) Niedrig PDF-Erstellung dauert > 10 s Queue-basierte Erzeugung (async Job), UI zeigt „Wird erstellt …"
R3 Token-URLs werden öffentlich geteilt (Social Media etc.) Mittel Unerwünschter Einblick Token-Expiry 14 Tage Standard, Viewer-Log zeigt auffälliges Öffnen
R4 travel_program_id in offer_items zeigt auf v2-Daten, die wegmigrieren Hoch Datenbezüge brechen nach v2-Migration Metadata speichert Titel/Preis zum Zeitpunkt der Erstellung — funktioniert auch bei fehlendem FK
R5 Mitarbeiter bearbeiten ein gesendetes Angebot unbewusst (Auto-Save) Mittel Inkonsistente Versionen Auto-Save nur bei isEditable()==true, sonst Felder disabled
R6 Konflikt mit Modul 3 Phase 4 (attachments) Hoch Doppelte Speicherorte Eigene offer_files-Tabelle jetzt, Migration in attachments ist eigenes Ticket nach Phase 4 — bewusst so geplant

4. Offene Detail-Entscheidungen (können während Umsetzung geklärt werden)

  1. Angebots-Nummernkreis: {YYYY}-{5-stellig} oder durchlaufend ohne Jahr? → Vorschlag: mit Jahr (leichter zu sortieren, klassisches Format).
  2. Subdomain für Kundenseite: /angebot/{token} auf mein.sterntours.de (einfacher) oder angebote.sterntours.de (professioneller für Kunden)? → Vorschlag: Subdomain, da Kunden nicht den Admin-Hostnamen sehen sollten. Technisch ist es dieselbe Laravel-App.
  3. WYSIWYG-Editor: TinyMCE (bereits im System für CMS) oder TipTap (moderner, lizenzfrei)? → Vorschlag: TinyMCE, da schon integriert.
  4. PDF-Engine: TCPDF / DomPDF (beide im System, siehe app/Libraries/) oder weiterer Kandidat (wkhtmltopdf)? → Vorschlag: DomPDF, da bereits für Booking-PDFs genutzt; konsistente Optik.
  5. Token-Gültigkeit Default: 14 Tage, 30 Tage, bis valid_until? → Vorschlag: bis valid_until + 7 Tage (Kunde soll nach Ablaufdatum noch die Ablehnungs-Bestätigung sehen können).

Diese 5 Punkte würde ich Ende S1 (nach Fundament) kurz final abstimmen, damit S2 sauber losfahren kann.


5. Abnahmeliste (Gesamt-Abschluss Modul 6)

  • Mitarbeiter kann aus Anfrage heraus in < 2 Minuten ein Angebot mit Vorlage erstellen.
  • Mitarbeiter kann PDF generieren und ansehen.
  • Mitarbeiter kann Angebot per E-Mail versenden; Kunde erhält PDF + Link.
  • Kunde kann über Link das Angebot ansehen und mit einem Klick annehmen oder ablehnen.
  • Mitarbeiter sieht Annahme im Dashboard und bekommt Benachrichtigung.
  • Aus angenommenem Angebot kann mit einem Klick eine Buchung erzeugt werden.
  • Nach Versand ist das Angebot read-only; Änderungen erzeugen eine neue Version.
  • Alte Versionen bleiben auffindbar und ihr PDF weiterhin downloadbar.
  • In Kontakt- und Anfrage-Ansicht ist die Anzahl der Angebote sichtbar und klickbar.
  • Abgelaufene Angebote werden automatisch auf expired gesetzt.
  • Alle neuen Routen haben Permission-Checks.
  • Feature-Tests grün, Unit-Test-Coverage ≥ 80 % im Service/Repository.
  • Dokumentation für Entwickler (dev/offers/README.md) und für Mitarbeiter (user-guide.md) vorhanden.