57 KiB
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+inquiriesexistieren. ✅ Test - Modul 3 Phase 4 (
attachments,communications) ideal — sonst Fallback mit eigeneroffer_files-Tabelle, später Migration inattachments. ⬜ 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-rowverfü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)
-
FK-Typ-Mismatch: Spec/erste Version nutzte
foreignId(...)→bigint unsigned. Die Legacy-Tabellen haben aber:contacts.id,inquiries.id,booking.id,branch.id→bigint(signed!)users.id→int unsigned
Fix in 3 Migrationen (100001, 100002, 100004): explizit
$t->bigInteger('contact_id')etc. + manueller$t->foreign(...)->references('id')->on('xxx'). Fürusers→unsignedInteger('created_by'). -
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 --forceSo werden ausschließlich die 7 Offer-Files geladen.
-
Class-Name-Bug entdeckt & gefixt:
database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.phpdeklarierte fälschlicherweiseclass CreateBookingVoucherTable(richtig:CreateBookingVoucherAgencyTable). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen. -
Verifikation OK: 15 neue FKs gesetzt (5 auf
offers, 3 aufoffer_versions, je 1–2 auf andere + 1 Reverse aufbooking.offer_id), Indizes/Unique-Constraints alle vorhanden. SieheSHOW CREATE TABLE-Outputs in den Smoke-Tests. -
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_100001bis100007 - 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)
- 6 Models angelegt:
Offer,OfferVersion,OfferItem,OfferTemplate,OfferFile,OfferAccessTokeninapp/Models/. Alle mitHasFactory,$connection='mysql', typisierten PHPDoc-Annotations,STATUS_*-Konstanten (statt String-Literals) und sauberenBelongsTo/HasMany-Relations. - Status-Konstanten: Stati sind als
self::STATUS_DRAFTetc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl.superseded). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet undwhereInmit Strings gut funktioniert. - OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self erzeugt
Str::random(64)-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Propertyplain_token. CounterpartfindActiveByPlainToken(string)hasht den eingehenden Klartext und scoped aufactive()(nicht widerrufen, nicht abgelaufen). - 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: Spalteinclude_in_pdfstattfrozen(semantisch klarer). - Smoke-Test (Tinker, in Transaktion + Rollback): Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading
currentVersion.items / .tokens / contactgrün,totalPriceFormatted() → "1.234,56 €",isEditable() → truefür Draft. - Offer-Inquiry-Relation zeigt auf Lead-Model: Die Tabelle heißt nach Phase 2
inquiries, das Eloquent-Model ist aber weiterhinApp\Models\Lead(Umbenennung des Models ist eigenes Ticket). Offer verwendet$this->belongsTo(Lead::class, 'inquiry_id').
A3 — Lessons Learned (2026-04-18)
- 4 Repositories angelegt:
OfferRepository,OfferVersionRepository,OfferTemplateRepository(extendsBaseRepository) undOfferFileRepository(extendsFileRepository). Alle mituse Illuminate\Support\Facades\DB;— konsistent mit Phase-1-Stil derContactsMergeDuplicates-Command. generateOfferNumber()race-safe umgesetzt:lockForUpdate()auf der höchstenoffer_number LIKE '{year}-%'-Zeile innerhalb einerDB::transaction; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordertYYYY-NNNNN.OfferRepository::create()atomar: Offer + Version#1 in einemDB::transaction-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.createNewVersion()dupliziert viareplicate(): Methode nutzt Eloquentsreplicate(['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).updateDraft()wirftDomainExceptionbei 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_pricewird danach ausSUM(items.total_price)neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.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 globalenow()/\Storage-Facades haben wir mitCarbon::now()bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.- 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)
OfferServicemit DI-Konstruktor (4 Repos injected). Kein Static-Helper wieApp\Services\Booking— der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.- 2 Custom Exceptions, sprechend:
OfferNotEditableException(OfferVersion $v, string $action)— trägtversionId+currentStatusals readonly Properties, Controller können fein unterscheiden.InvalidOfferStatusException(Offer $o, array $expected, string $action)— trägtofferId,currentStatus,expectedStatuses[].
- 4 Domain-Events (
OfferSent,OfferAccepted,OfferDeclined,OfferWithdrawn) inapp/Events/Offer/. Dispatchen perEvent::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 odercustomer_mails. 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 dercurrent_version_id-Version, nur im Statusdraft.supersede()revoked alte Tokens: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Callrevoked_at IS NOT NULLhat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.markDeclined()akzeptiertsentUNDaccepted: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend aufdeclinedgesetzt.purgeExpired()respektiert Offer-Hierarchie: Setzt nur dann auch den Offer aufexpired, wenn der Offer (a) nochsentist und (b) die expirierte Version diecurrent_version_idist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).convertToBooking()nur als Guards+Stub: Wirft sauberInvalidOfferStatusException(not accepted) undDomainException(double-invoke), aber der eigentliche Booking-Create hängt anBookingService::createManual()aus Modul 4 und wird in Ticket B8 finalisiert.- 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.
- 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)
- Erste
FormRequests im CRM-Projekt (vorher keineapp/Http/Requests/-Klasse). Muster:Illuminate\Foundation\Http\FormRequest,authorize() =>eingeloggter User,rules(), ggf.withValidator/prepareForValidation. - StoreOfferRequest — Kreuz-Validierung: Mindestens eines von
contact_id/inquiry_idmuss gesetzt sein (withValidator+ Fehler aufcontact_id).template_idmitRule::exists('offer_templates','id')->whereNull('deleted_at')(keine gelöschte Vorlage). - UpdateVersionRequest — WYSIWYG:
intro_text/itinerary_text/closing_textmitstrip_tags+ Whitelist;headlineohne Whitelist. Kein Mews/HtmlPurifier incomposer.json— bei Bedarf nachziehen. - Preise in Positionen:
items.*.price_per_unit=numeric; inwithValidatorfür Typen außerdiscountWert>= 0erzwingen. - valid_until →
after_or_equal:today(nicht in der Vergangenheit). SendOfferRequestexpires_at→after:now(für E-Mail-Token echter Zeitpunkt in der Zukunft). - cc/bcc als langer String; Aufteilen in einzelne Adressen erst im Mail-Listener (B6).
- Datei-Upload-Regeln (MIME wie BookingFile) verbleiben am Upload-Pfad (wie
FileRepositorybei Buchungen) — in A6 kann bei Bedarf einUploadOfferFileRequestergänzt werden.
A6 — Lessons Learned (2026-04-24)
- Routen im bestehenden
admin+2fa-Block (nichtauth.2fader Spec — im Projekt heißt es2fa+AdminMiddleware). Permission-Gates mitauth.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). - 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 inusers.permissions— nach Deploy im User-Rechte-UI setzen (SuperAdmin) oder JSON per SQL ergänzen; beisetPermissionsDefault()greifen sie nur, wennpermissionsleer/ungültig ist. - Fein-Permissions in
OfferController@action: proactionwerdenoffers-w(Mutation),offers-send(send/resend),offers-accept(Zusage/Absage/withdraw) geprüft; unbekannte Aktionen verlangenoffers-w. - Menü
layout-sidenavzwischen „Anfragen“ und „Kunden“: sichtbar beioffers-roderoffers-w(nur-Writer-Edge-Case), Iconion-md-document, aktive Pfadeoffers/offer/*. - Stub-UI:
resources/views/offer/index.blade.phpmit server-side DataTable (data_table_offers);detail+ Vorlagen-Views + PDF sind Platzhalter für B1/B3/B5/C1. - Disk
offerwar bereits inconfig/filesystems.php—OfferFileControllersetztdisk=offerwie beim Booking-Upload.
A7 — Lessons Learned (2026-04-24)
ContactFactoryergänzt:Offerverlangtcontact_id; ohne Factory müsste jeder Test-Kontakt manuell angelegt werden.Contacthatte bereitsHasFactory, es fehlte nur die Klassendatei unterdatabase/factories/.OfferFactory+ V1 ohne Rekursion: ErsteOfferVersionwird inOfferFactory::afterCreating()perOfferVersion::query()->create()angelegt undcurrent_version_idgesetzt — keinOfferVersion::factory()mit verschachteltemOffer::factory()(unique(offer_id, version_no)und zyklische FKs).OfferVersionFactory: Standalone-definition()nutztoffer_id→Offer::factory()undversion_no= 2, weilOffer::factory()automatisch V1 erzeugt. Für manuelle Tests:forOffer($offer, $n)mit frei wählbarer Versionsnummer.- States:
Offer::factory()->sent()/->accepted()setzen Offer-Status und synchronisieren die aktuelle Version (refresh()->currentVersion).OfferVersionFactoryhat zusätzlichversionSent()/versionAccepted()für reine Versions-Snapshots. OfferItemFactory: HilfsmethodenforCurrentVersionOf(Offer)undforVersion(OfferVersion);asDiscount()für Rabattzeilen. Erste Position nutztOfferItem::TYPE_TRAVEL.OfferTemplateSeeder: Fünf realistische Vorlagen perupdateOrCreate(['name' => …]);created_by= kleinster existierenderusers.id(ohneUser::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.- Smoke-Test: An dieser Umgebung war kein MySQL-Hostname erreichbar (
getaddrinfo for global-mysql); Syntax-Checkphp -lgrün. Lokal/CI mit laufender DB:Offer::factory()->create()+OfferItem::factory()->forCurrentVersionOf($o).
0. Übersicht
Insgesamt 6 Phasen mit 32 Tickets. Geschätzter Gesamtaufwand: 7–9 Wochen (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
| Phase | Inhalt | Tickets | Aufwand |
|---|---|---|---|
| A | Fundament (DB / Models / Repositories / Services / Routing / Permissions) | A1–A7 | ~10 Tage |
| B | Admin-UI (Mitarbeiter-Backend) | B1–B9 | ~18 Tage |
| C | Vorlagen (Offer-Templates) | C1–C3 | ~3 Tage |
| D | Kundenseitiger Freigabe-Link (öffentliches Frontend) | D1–D5 | ~5 Tage |
| E | Versionierung & Archivierung | E1–E3 | ~3 Tage |
| F | Polish, Automatik, Tests | F1–F6 | ~5 Tage |
Nach Abschluss von Phase B5 kann D parallel zu C + E gebaut werden.
1. Reihenfolge und kritischer Pfad
A1 → A2 → A3 → A4 → A5 → A6 → A7 ─────────┐
│
┌─── B1 → B2 → B3 → B4 → B5 ──────┼──► B6 → B7 ─┐
│ │ │
│ ┌── C1 → C2 ─┤ ├─► B8 → B9
│ │ │ │
│ ├── D1 → D2 → D3 → D4 → D5─┤
│ │ │
│ └── E1 → E2 → E3 ──────────┘
│
└──► F1 … F6 (laufend)
Phase A — Fundament
Ticket A1 — Datenbank-Migrationen anlegen
Typ: Migration
Aufwand: 1 Tag
Abhängigkeiten: Modul 3 Phase 2 (contacts, inquiries)
Dateien (neu):
database/migrations/2026_05_01_100001_create_offers_table.phpdatabase/migrations/2026_05_01_100002_create_offer_versions_table.phpdatabase/migrations/2026_05_01_100003_create_offer_items_table.phpdatabase/migrations/2026_05_01_100004_create_offer_templates_table.phpdatabase/migrations/2026_05_01_100005_create_offer_files_table.phpdatabase/migrations/2026_05_01_100006_create_offer_access_tokens_table.phpdatabase/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) undmigrate:rollbackinvertiert sie. - FKs sind konsistent; kein Drop einer Tabelle bricht andere.
- Mit
migrate:fresh --seedreproduzierbar.
Risiken:
travel_program_id/fewo_lodging_idhaben 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.phpapp/Models/OfferVersion.phpapp/Models/OfferItem.phpapp/Models/OfferTemplate.phpapp/Models/OfferFile.phpapp/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 zuBookingFile::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->itemsliefert korrekte Collection.OfferAccessToken::generate()erzeugt unique Token, Kollisionsschutz perunique-Constraint.
Ticket A3 — Repositories
Typ: Code Aufwand: 1 Tag Abhängigkeiten: A2
Dateien (neu):
app/Repositories/OfferRepository.phpextendsBaseRepositoryapp/Repositories/OfferVersionRepository.phpextendsBaseRepositoryapp/Repositories/OfferFileRepository.phpextendsFileRepository(analogBookingFileRepository)app/Repositories/OfferTemplateRepository.phpextendsBaseRepository
OfferRepository:
create(array $data, int $contactId, ?int $inquiryId = null): Offer— legtOffer+OfferVersion#1(status=draft) an, setztcurrent_version_id.generateOfferNumber(): string— Format{YYYY}-{5-stellig laufend}, nutztDB::transactionmitSELECT … FOR UPDATEauf einer Zähler-Tabelle ODER nimmtMAX(offer_number)mit Transaktion.findByNumber(string $no): ?Offer
OfferVersionRepository:
updateDraft(OfferVersion $v, array $data): OfferVersion— nur wennstatus=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 wirdsuperseded,offer.current_version_idwird aktualisiert. Atomar in Transaktion.markSent(OfferVersion $v): OfferVersion— setzt status,sent_at,frozenauf allenoffer_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 wieBookingFileRepository::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 Versionsuperseded.
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 Versionstatus=draftundcurrent_versionder Offer ist.markAccepted()nur möglich beistatus=sent; Offer.status wechselt ebenfalls aufaccepted; ältere noch aktive Versionen werdensuperseded.supersede()deaktiviert bestehenden Token, erzeugt aber beim nächstensend()einen neuen.convertToBooking()nur bei Offer.status=accepted; nutztBookingService::createManual($offer->currentVersion->toBookingData()); setztoffer.booking_id, kopiertoffer_filesalsbooking_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.phpapp/Http/Requests/Offer/UpdateVersionRequest.phpapp/Http/Requests/Offer/SendOfferRequest.phpapp/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 zubookings/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(FKoffers.contact_id)database/factories/OfferFactory.phpdatabase/factories/OfferVersionFactory.phpdatabase/factories/OfferItemFactory.phpdatabase/factories/OfferTemplateFactory.phpdatabase/seeds/OfferTemplateSeeder.php(NamespaceDatabase\Seeders, vgl.composer.jsonPSR-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— Methodeindex(),getOffers().resources/views/offer/index.blade.phpresources/views/offer/_list_row.blade.php(optional, wenn DataTable serverseitig gerendert)
Ablauf:
- DataTable (Server-side via
DataTableController-Pattern, siehedata/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>):draftgrau,sentblau,acceptedgrün,declinedrot,expiredgelb,withdrawnschwarz.
- 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.phpresources/views/offer/create.blade.php(leiterer Wizard-Einstieg)
UI-Fluss:
- Modal öffnet aus zwei Kontexten:
- Global:
/offers→ Toolbar „Neues Angebot". - Aus Anfrage:
/inquiry/detail/{id}→ Button „Angebot erstellen" (neu) — pre-fillt Kontakt + Anfrage.
- Global:
- Modal-Felder: Kontakt (Autocomplete
/data/table/contacts), optional Anfrage (Autocomplete Inquiries des Kontakts), Vorlage (Dropdownoffer_templates). - Bei Submit:
POST /offer/action/create→ legt viaOfferService::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 zubooking/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) + Closingresources/views/offer/_detail_items.blade.php— Positions-Liste (Drag&Drop via SortableJS)resources/views/offer/_detail_items_row.blade.php— einzelne Positionresources/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-Flagautosave-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,
positionwird gespeichert. - Summe stimmt mit
total_pricein 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 zuBookingController::action()„upload-booking-file")resources/views/offer/_detail_documents.blade.phpresources/views/offer/modal-new-offer-files.blade.php(Dropzone, nutzt gleiche Blade-Partials wiebooking/modal-new-booking-files)
UI: Zwei Bereiche:
- Zentral hinterlegte Dokumente (Checkboxen):
- Liste kommt aus einem neuen Mini-Modell
OfferDocumentTemplateoder 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).
- Liste kommt aus einem neuen Mini-Modell
- 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.
- Dropzone →
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 zuCreatePDF.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
OfferVersionerzeugt (nicht für das übergeordneteoffer). - 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_textgefü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_pathgespeichert; 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 anOfferService::send().
Logik:
- 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}). - User kann Betreff/Body anpassen.
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=trueaufoffer_filesder Version.
- Erzeugt neuen
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.phpresources/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:- Guard: Offer.status=accepted, Offer.booking_id IS NULL.
- Baut
$bookingDataaus aktueller OfferVersion (Items → BookingServiceItems, Summe → BookingPrice, Kontakt → Customer, Reisedatum → aus Items oder aus Offer-Meta). - Ruft
BookingService::createManual($bookingData, $offer->inquiry)auf (Modul 4). - Kopiert
offer_files(frozen) alsbooking_files. - Setzt
offer.booking_id, persistiert. - 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-Modaloffer/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
withdrawnwä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.phpresources/views/offer_template/index.blade.phpresources/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 ohneauth.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.phpapp/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_atwird 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.phpresources/views/public/offer/_actions.blade.phpresources/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, ruftOfferService::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_atgesetzt, 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
communicationan (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=trueund Datei nicht auf Hot-Disk. - Konfiguration über
config/offers.phpsteuerbar.
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.phpneue 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
OfferVersionmitvalid_until < todayundstatus=sentaufexpired. - 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(undoffer_version_id) incommunicationshinterlegt. - 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.phptests/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.mdmit Funktionsübersicht.dev/offers/user-guide.md— Anleitung für Mitarbeiter (mit Screenshots).- Eintrag in
CLAUDE.md/mein.sterntours.de/CLAUDE.mdfü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-wan. - 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)
- Angebots-Nummernkreis:
{YYYY}-{5-stellig}oder durchlaufend ohne Jahr? → Vorschlag: mit Jahr (leichter zu sortieren, klassisches Format). - Subdomain für Kundenseite:
/angebot/{token}aufmein.sterntours.de(einfacher) oderangebote.sterntours.de(professioneller für Kunden)? → Vorschlag: Subdomain, da Kunden nicht den Admin-Hostnamen sehen sollten. Technisch ist es dieselbe Laravel-App. - WYSIWYG-Editor: TinyMCE (bereits im System für CMS) oder TipTap (moderner, lizenzfrei)? → Vorschlag: TinyMCE, da schon integriert.
- 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. - Token-Gültigkeit Default: 14 Tage, 30 Tage, bis
valid_until? → Vorschlag: bisvalid_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
expiredgesetzt. - 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.