Neustrukturierung Customer / Lead / Booking Phase 2

This commit is contained in:
Kevin Adametz 2026-05-28 17:10:37 +02:00
parent 313f0dbf4e
commit 6df9c401af
69 changed files with 3809 additions and 374 deletions

324
dev/NEWSLETTER.md Normal file
View file

@ -0,0 +1,324 @@
# Newsletter-Modul Dokumentation
## Übersicht
Das Newsletter-Modul ermöglicht die zentrale Verwaltung von Newsletter-Kontakten aus beiden Buchungssystemen (Kulturreisen und Ferienwohnungen). Es synchronisiert automatisch Kundendaten aus Buchungen und bietet Export-Funktionen für Newsletter-Kampagnen.
## Features
- ✅ Zentrale Kontaktverwaltung für beide Newsletter-Gruppen
- ✅ Automatische Synchronisation aus Buchungssystemen
- ✅ Duplikat-Erkennung über E-Mail-Adressen
- ✅ Tracking von Buchungsstatistiken
- ✅ Status-Verwaltung (Aktiv, Inaktiv, Abgemeldet, Bounced)
- ✅ Herkunfts-Tracking (Buchung, Newsletter-Anmeldung, etc.)
- ✅ Export-Funktion (CSV)
- ✅ Aktivitäts-Log für jeden Kontakt
- ✅ Filter- und Suchfunktionen
## Installation
### 1. Migrationen ausführen
```bash
./vendor/bin/sail artisan migrate
```
Dies erstellt folgende Tabellen:
- `newsletter_contacts` - Haupt-Kontakte-Tabelle
- `newsletter_logs` - Aktivitäts-Log
### 2. Berechtigung hinzufügen
In der Datenbank muss die Berechtigung `cms-newsletter` für Benutzer aktiviert werden, die Zugriff auf das Newsletter-Modul haben sollen.
```sql
-- Beispiel: Berechtigung für einen User aktivieren
-- Dies muss über das bestehende Berechtigungssystem erfolgen
```
### 3. Erste Synchronisation
Nach der Installation sollte eine vollständige Synchronisation durchgeführt werden:
```bash
# Alle Buchungen synchronisieren
./vendor/bin/sail artisan newsletter:sync-kulturreisen --force
./vendor/bin/sail artisan newsletter:sync-ferienwohnungen --force
```
## Verwendung
### Admin-Panel
Das Newsletter-Modul ist im Admin-Panel unter **CMS > Inhalte > Newsletter** erreichbar.
#### Hauptfunktionen:
1. **Kontaktliste**
- Übersicht aller Newsletter-Kontakte
- Filter nach Gruppe, Status, Herkunft
- Suchfunktion nach E-Mail und Name
- DataTables mit Sortierung und Pagination
2. **Kontakt-Detail**
- Vollständige Kontaktinformationen
- Buchungsstatistiken
- Aktivitäts-Log
- Verlinkung zu Original-Kundendaten
3. **Kontakt bearbeiten**
- Manuelles Erstellen von Kontakten
- Bearbeiten von Kontaktdaten
- Status-Änderung
- Gruppen-Zuweisung
4. **Synchronisation**
- Manuelle Synchronisation über UI
- Incremental Sync (letzte 30 Tage)
- Full Sync (alle Buchungen)
5. **Export**
- Export nach Gruppe
- Export nach Status
- CSV-Format
- Vorkonfigurierte Export-Optionen
### Artisan-Befehle
#### Synchronisation Kulturreisen
```bash
# Incremental Sync (letzte 30 Tage)
./vendor/bin/sail artisan newsletter:sync-kulturreisen
# Full Sync (alle Buchungen)
./vendor/bin/sail artisan newsletter:sync-kulturreisen --force
```
Dieser Befehl:
- Liest alle Buchungen aus der `booking` Tabelle
- Verknüpft mit `customer` Daten
- Erstellt oder aktualisiert Newsletter-Kontakte
- Zählt Buchungen pro Kunde
- Trackt letzte Buchung
#### Synchronisation Ferienwohnungen
```bash
# Incremental Sync (letzte 30 Tage)
./vendor/bin/sail artisan newsletter:sync-ferienwohnungen
# Full Sync (alle Buchungen)
./vendor/bin/sail artisan newsletter:sync-ferienwohnungen --force
```
Dieser Befehl:
- Liest Buchungen aus `travel_user_booking_fewos` mit `invoice_number`
- Nur Buchungen mit reiner Nummer (keine Storno etc.)
- Verknüpft mit `travel_users` Daten
- Erstellt oder aktualisiert Newsletter-Kontakte
- Zählt Buchungen pro Kunde
### Automatische Synchronisation
Für regelmäßige Synchronisation empfiehlt sich ein Cron-Job:
```bash
# In der crontab
# Täglich um 2 Uhr morgens
0 2 * * * cd /var/www/html && ./vendor/bin/sail artisan newsletter:sync-kulturreisen
0 2 * * * cd /var/www/html && ./vendor/bin/sail artisan newsletter:sync-ferienwohnungen
```
Oder im Laravel Scheduler (`app/Console/Kernel.php`):
```php
protected function schedule(Schedule $schedule)
{
// Täglich um 2 Uhr
$schedule->command('newsletter:sync-kulturreisen')->dailyAt('02:00');
$schedule->command('newsletter:sync-ferienwohnungen')->dailyAt('02:15');
}
```
## Datenmodell
### Newsletter Contact
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | bigint | Primary Key |
| `email` | string | E-Mail-Adresse (unique) |
| `firstname` | string | Vorname |
| `lastname` | string | Nachname |
| `group_kulturreisen` | boolean | Zugehörigkeit zur Gruppe Kulturreisen |
| `group_ferienwohnungen` | boolean | Zugehörigkeit zur Gruppe Ferienwohnungen |
| `source` | enum | Herkunft des Kontakts |
| `status` | enum | Status (active, inactive, unsubscribed, bounced) |
| `subscribed_at` | timestamp | Zeitpunkt der Anmeldung |
| `unsubscribed_at` | timestamp | Zeitpunkt der Abmeldung |
| `last_booking_at` | timestamp | Zeitpunkt der letzten Buchung |
| `total_bookings_kulturreisen` | int | Anzahl Buchungen Kulturreisen |
| `total_bookings_ferienwohnungen` | int | Anzahl Buchungen Ferienwohnungen |
| `customer_id` | int | Referenz zu `customer` (Kulturreisen) |
| `travel_user_id` | int | Referenz zu `travel_users` (Ferienwohnungen) |
| `last_synced_at` | timestamp | Letzte Synchronisation |
| `sync_hash` | string | Hash für Duplikat-Erkennung |
| `notes` | text | Notizen |
### Newsletter Log
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | bigint | Primary Key |
| `newsletter_contact_id` | bigint | Foreign Key zu newsletter_contacts |
| `action` | enum | Aktion (subscribed, unsubscribed, etc.) |
| `description` | string | Beschreibung der Aktion |
| `metadata` | json | Zusätzliche Daten |
| `user_id` | int | Admin-User der die Aktion ausgeführt hat |
## Status-Typen
- **active**: Aktiver Newsletter-Empfänger
- **inactive**: Inaktiv (z.B. temporär deaktiviert)
- **unsubscribed**: Abgemeldet vom Newsletter
- **bounced**: E-Mail nicht zustellbar (Bounced)
## Herkunfts-Typen
- **booking_kulturreisen**: Aus Kulturreisen-Buchung
- **booking_ferienwohnungen**: Aus Ferienwohnungs-Buchung
- **newsletter_signup**: Newsletter-Anmeldung über Formular
- **manual**: Manuell erstellt
- **import**: Über Import hinzugefügt
## Export-Formate
### CSV-Export
Der CSV-Export enthält folgende Spalten:
1. ID
2. E-Mail
3. Vorname
4. Nachname
5. Gruppe Kulturreisen (Ja/Nein)
6. Gruppe Ferienwohnungen (Ja/Nein)
7. Status
8. Herkunft
9. Buchungen Kulturreisen
10. Buchungen Ferienwohnungen
11. Letzte Buchung
12. Angemeldet am
13. Abgemeldet am
14. Erstellt am
## Best Practices
### Duplikat-Vermeidung
Das System verwendet einen Hash basierend auf E-Mail und Quelle zur Duplikat-Erkennung. Kontakte werden automatisch zusammengeführt, wenn:
- Dieselbe E-Mail-Adresse in beiden Systemen vorkommt
- Der neuere Datensatz wird bevorzugt
- Gruppenzugehörigkeiten werden kombiniert
### Datenschutz
- Soft-Delete: Gelöschte Kontakte werden nicht permanent entfernt
- Log-Tracking: Alle Änderungen werden protokolliert
- Abmelde-Funktion: Kontakte können sich jederzeit abmelden
- Export-Kontrolle: Nur autorisierte Benutzer können exportieren
### Performance
- Incremental Sync: Standardmäßig werden nur die letzten 30 Tage synchronisiert
- Indexierung: E-Mail, Status und Gruppen sind indexiert
- DataTables: Server-Side Processing für große Datenmengen
- Batch-Processing: Synchronisation verarbeitet Datensätze effizient
## Troubleshooting
### Problem: Synchronisation schlägt fehl
**Lösung:**
1. Prüfe Datenbankverbindungen (beide Datenbanken müssen erreichbar sein)
2. Prüfe Logs: `storage/logs/laravel.log`
3. Führe Sync mit `--force` aus für vollständige Synchronisation
### Problem: Duplikate in der Liste
**Lösung:**
1. Prüfe `sync_hash` Spalte
2. Führe manuelle Bereinigung durch
3. Kontakte können manuell zusammengeführt werden
### Problem: Export enthält keine Daten
**Lösung:**
1. Prüfe Filter-Einstellungen
2. Prüfe Status der Kontakte
3. Prüfe Berechtigungen
## API-Endpunkte
Alle Routen sind unter dem Middleware-Schutz `['admin', '2fa', 'auth.permission:cms-newsletter']`.
| Methode | Route | Beschreibung |
|---------|-------|--------------|
| GET | `/newsletter` | Liste aller Kontakte |
| GET | `/newsletter/datatable` | DataTables AJAX |
| GET | `/newsletter/{id}` | Detail eines Kontakts |
| GET | `/newsletter/{id}/edit` | Bearbeitungsformular |
| POST | `/newsletter/{id}/store` | Speichern |
| DELETE | `/newsletter/{id}` | Löschen (soft delete) |
| POST | `/newsletter/{id}/unsubscribe` | Abmelden |
| POST | `/newsletter/{id}/resubscribe` | Wieder aktivieren |
| POST | `/newsletter/sync` | Synchronisation starten |
| GET | `/newsletter/export` | Export |
## Erweiterungen
### Integration mit Newsletter-Diensten
Das Modul kann einfach mit externen Newsletter-Diensten (Mailchimp, SendGrid, etc.) integriert werden:
```php
// Beispiel: Export für Mailchimp
$contacts = NewsletterContact::active()->kulturreisen()->get();
foreach ($contacts as $contact) {
// Mailchimp API Call
}
```
### Webhook für Abmeldungen
Eine öffentliche Abmelde-Route kann hinzugefügt werden:
```php
// routes/web.php
Route::get('/newsletter/unsubscribe/{token}', 'NewsletterPublicController@unsubscribe');
```
### Double-Opt-In
Für Newsletter-Anmeldungen kann ein Double-Opt-In Prozess implementiert werden.
## Support
Bei Fragen oder Problemen:
1. Prüfe diese Dokumentation
2. Prüfe Laravel Logs
3. Prüfe Artisan Command Output
4. Kontaktiere das Entwickler-Team
## Changelog
### Version 1.0.0 (2025-11-07)
- ✅ Initiales Release
- ✅ Kontaktverwaltung
- ✅ Synchronisation Kulturreisen
- ✅ Synchronisation Ferienwohnungen
- ✅ Export-Funktion
- ✅ Admin-Panel Integration

View file

@ -1,10 +1,11 @@
# Umsetzung: Neustrukturierung Customer / Lead / Booking
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Phase 2 auf **Test** erfolgreich migriert und verifiziert — bereit für Live-Deploy.
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Hotfix gegen Import-Bug ausgespielt (2026-04-18); Phase 2 auf **Test** erfolgreich migriert und verifiziert — Phase-2-Live-Deploy nach Stabilitätsphase.
**Erstellt:** April 2025
**Letzte Aktualisierung:** 2026-04-17
**Letzte Aktualisierung:** 2026-04-18
**Konzept:** [konzept.md](konzept.md)
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
**Hotfix-Dokumentation:** [../hotfix-2026-04-18/HOTFIX.md](../hotfix-2026-04-18/HOTFIX.md)
---
@ -18,6 +19,7 @@
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ✅ Abgeschlossen | ✅ **Ja, Batch 2729** | ⬜ Nein (Deploy-Handbuch fertig) |
| Phase 2 — Smoke-Test-Fixes 2026-04-17 (`Lead::bookings()` FK; `orderColumn`-SQL in 4 Controllern; Blade-Column-`name:` in `contact/index.blade.php`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt) |
| Phase-1-Hotfix — nullable Parameter (`?int $mailDirId`, `?string $subdir`) in 3 Service-Klassen (Mail-Dialoge mit NULL-Werten) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt, siehe Handbuch) |
| **Hotfix 2026-04-18**`BookingImport.php` auf Live: `'inquiry_id' → 'lead_id'` (Phase-2-Datei versehentlich beim Phase-1-Deploy gerutscht, neue Bookings mit `lead_id=NULL`); Reparatur-DB-Query + 2 Artisan-Commands (`bookings:resync-from-travel-bookings`, `bookings:repair-from-travel-bookings`) | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-18** |
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |

View file

@ -0,0 +1,172 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\Booking;
use App\Models\Customer;
use App\Models\Arrangement;
use App\Models\Participant;
use App\Models\TravelBooking;
use App\Models\BookingServiceItem;
use App\Repositories\DraftRepository;
class BookingImport
{
public static function importFrom(TravelBooking $travel_booking){
// ---- createCustomer
$data = [
'salutation_id' => $travel_booking->salutation_id,
'name' => $travel_booking->last_name,
'firstname' => $travel_booking->first_name,
'street' => $travel_booking->street,
'zip' => $travel_booking->zipcode,
'city' => $travel_booking->city,
'country_id' => $travel_booking->country_id,
'phone' => $travel_booking->phone,
'phonemobile' => $travel_booking->mobile,
'email' => $travel_booking->email
];
$customer = Customer::create($data);
// ---- createLead
$data = [
'customer_id' => $customer->id,
'request_date' => $travel_booking->created,
'travelperiod_start' => $travel_booking->selected_start_date,
'travelperiod_end' => $travel_booking->selected_end_date,
'remarks' => $travel_booking->comments,
'sf_guard_user_id' => 15,
'is_closed' => true,
'initialcontacttype_id' => 14,
'status_id' => 7,
'website_id' => 1,
];
$lead = Lead::create($data);
$lead->updateNextDueDate();
$comfort = false;
if(isset($travel_booking->drafts['comfort']) && $travel_booking->drafts['comfort']){
$comfort = true;
}
$data = [
'booking_date' => $travel_booking->created->format('Y-m-d'),
'customer_id' => $customer->id,
'lead_id' => $lead->id,
'new_drafts' => $travel_booking->drafts === null ? 0 : 1,
'sf_guard_user_id' => 15,
'branch_id' => 4,
'pax' => $travel_booking->selected_adults,
'title' => isset($travel_booking->selected_travel['travel_title']) ? $travel_booking->selected_travel['travel_title'] : "",
'comfort' => $comfort,
'start_date' => $travel_booking->selected_start_date->format('Y-m-d'),
'end_date' => $travel_booking->selected_end_date->format('Y-m-d'),
'website_id' => 1,
'travel_number' => isset($travel_booking->selected_travel['travel_number']) ? $travel_booking->selected_travel['travel_number'] : null,
/*'participant_name' => isset($travel_booking->participants[0]['last_name']) ? $travel_booking->participants[0]['last_name'] : null,
'participant_firstname' => isset($travel_booking->participants[0]['first_name']) ? $travel_booking->participants[0]['first_name'] : null,
'participant_birthdate' => isset($travel_booking->participants[0]['birthday']) ? date( "Y-m-d", strtotime($travel_booking->participants[0]['birthday'])) : null,
'participant_salutation_id' => isset($travel_booking->participants[0]['gender']) ? $travel_booking->participants[0]['gender'] : null,
'nationality_id' => isset($travel_booking->participants[0]['nationality']) ? $travel_booking->participants[0]['nationality'] : null,*/
'participant_name' => null,
'participant_firstname' => null,
'participant_birthdate' => null,
'participant_salutation_id' => null,
'nationality_id' => null,
'price' => $travel_booking->price,
'price_total' => $travel_booking->price_total,
'deposit_total' => $travel_booking->deposit_total,
'final_payment' => $travel_booking->final_payment,
'final_payment_date' => $travel_booking->final_payment_date->format('Y-m-d'),
'travel_country_id' => isset($travel_booking->selected_travel['travel_country_id'][0]) ? $travel_booking->selected_travel['travel_country_id'][0] : null,
'travel_category_id' => isset($travel_booking->selected_travel['travel_category_id']) ? $travel_booking->selected_travel['travel_category_id'] : null,
'travelagenda_id' => isset($travel_booking->selected_travel['travelagenda_id']) ? $travel_booking->selected_travel['travelagenda_id'] : null,
'travel_company_id' => isset($travel_booking->selected_travel['travel_company_id']) ? $travel_booking->selected_travel['travel_company_id'] : 4,
'insurance_offer' => $travel_booking->insurance_offer,
];
//createBooking
$booking = Booking::create($data);
//createTraveler
if($travel_booking->participants){
foreach ($travel_booking->participants as $key => $participant){
Participant::create([
'booking_id' => $booking->id,
'participant_name' => $participant['last_name'],
'participant_firstname' => $participant['first_name'],
'participant_birthdate' => date( "Y-m-d", strtotime($participant['birthday'])),
'participant_salutation_id' => $participant['gender'],
'participant_child' => $participant['child'],
'nationality_id' =>$participant['nationality'],
]);
}
}
//createServiceItem //service_items
if($travel_booking->service_items){
foreach ($travel_booking->service_items as $key => $service_item){
BookingServiceItem::create([
'booking_id' => $booking->id,
'travel_company_id' => $service_item['travel_company_id'],
'service_price' => $service_item['service_price'],
'service_price_refund' => 0,
'commission' => $service_item['commission'],
'travel_date' => $service_item['travel_date'],
'name' => $service_item['name'],
'is_commission_locked' => 0,
]);
}
}
//createServiceItem //insurances
if($travel_booking->insurances){
foreach ($travel_booking->insurances as $service_item){
BookingServiceItem::create([
'booking_id' => $booking->id,
'travel_company_id' => $service_item['travel_company_id'],
'service_price' => $service_item['price'],
'service_price_refund' => 0,
'commission' => $service_item['commission'],
'travel_date' => $service_item['travel_date'],
'name' => $service_item['name'],
'is_commission_locked' => 0,
]);
}
}
//createArrangement
if($travel_booking->arrangements) {
foreach ($travel_booking->arrangements as $key => $arrangement){
Arrangement::create([
'state' => isset($arrangement['state']) ? $arrangement['state'] : null,
'begin' => isset($arrangement['end']) ? $arrangement['end'] : null,
'end' => isset($arrangement['end']) ? $arrangement['end'] : null,
'type_s' => isset($arrangement['type_s']) ? $arrangement['type_s'] : null,
'data_s' => isset($arrangement['data_s']) ? $arrangement['data_s'] : null,
'view_position' => isset($arrangement['view_position']) ? $arrangement['view_position'] : null,
'booking_id' => $booking->id,
'type_id' => $arrangement['type_id'],
'in_pdf' => isset($arrangement['in_pdf']) ? $arrangement['in_pdf'] : null,
]);
}
}
//createDrafts
if($travel_booking->drafts) {
$draftRepo = new DraftRepository($booking);
$draftRepo->create_drafts_from_booking($booking->id, $travel_booking->drafts);
}
$travel_booking->crm_booking_id = $booking->id;
$travel_booking->save();
return $booking;
}
}

View file

@ -0,0 +1,254 @@
# Hotfix 2026-04-18 — Fehlende `booking.lead_id` bei neu importierten Buchungen
**Status:** Dokumentation des am 2026-04-17/18 aufgetretenen Live-Bugs und der durchgeführten Korrekturen.
**Zielsystem:** Live (`mein.sterntours.de`, Phase 1)
**Abhängigkeit:** Phase-1-Deploy vom 2026-04-17 — NICHT vermischen mit Phase-2-Deploy.
---
## 1. Symptom
Auf Live wurden ab dem 2026-04-17 neu eingehende Anfragen über die API zwar in
`contact` (ehem. `customer`) und `lead` sauber angelegt, aber das zugehörige
`booking` blieb in den alten Views „frei schwebend":
- Die Buchung war im Kontakt-Detail sichtbar (über `booking.customer_id`).
- In der Lead-Ansicht tauchte sie NICHT auf (`lead -> bookings`).
- In der Booking-Liste wurde die Verknüpfung zum Lead nicht angezeigt.
- In der DB: `booking.lead_id = NULL` bei jedem neuen Datensatz seit Phase-1-Deploy.
Zusätzlich trat in Einzelfällen auf, dass ein importiertes Booking keine
Programm-Verknüpfung hatte (`title`, `travel_number`, `travelagenda_id` etc. leer).
## 2. Root Cause
Beim Phase-1-Deploy am 2026-04-17 ist die **Phase-2-Version** von
`app/Services/BookingImport.php` versehentlich mit auf Live gelandet. Diese
Datei enthält die für Phase 2 vorbereitete Zeile:
```diff
- 'lead_id' => $lead->id, // Phase 1 (korrekt für Live)
+ 'inquiry_id' => $lead->id, // Phase 2 (Workspace-Stand)
```
Auf Live existiert die Spalte `booking.inquiry_id` (noch) nicht — sie entsteht
erst in Phase 2 durch das Rename `booking.lead_id → booking.inquiry_id`. Und
das Live-`Booking`-Model hat `'inquiry_id'` nicht in `$fillable`. Folge:
- `Booking::create(['inquiry_id' => 42, ...])` → Eloquent ignoriert das Feld
stumm (mass-assignment guard).
- `customer_id` wird trotzdem korrekt gesetzt → Kontakt sieht die Buchung.
- `lead_id` bleibt `NULL` → alle leadbasierten Views finden die Buchung nicht.
Warum Programm-Felder in Einzelfällen fehlten, ist davon unabhängig: wenn
`travel_booking.selected_travel` zum Import-Zeitpunkt leer/unvollständig war,
übernimmt `BookingImport` die sechs Programm-Felder (`title`, `travel_number`,
`travel_country_id`, `travel_category_id`, `travelagenda_id`,
`travel_company_id`) als `null`.
## 3. Verifikation des Root Cause
Git-Commit `5a74789 deplay phase 1` vs. aktueller Workspace-Stand
(`ba48745 phase 2 dev`):
```bash
cd /workspace/mein.sterntours.de
git diff 5a74789 HEAD -- app/Services/BookingImport.php
# → Zeigt genau die `'lead_id'``'inquiry_id'` Zeile.
```
Auf Live gegengecheckt mit:
```bash
grep -n "lead_id\|inquiry_id" app/Services/BookingImport.php
# → Live zeigte 'inquiry_id' (Bug bestätigt)
```
## 4. Sofort-Hotfix (erledigt)
### 4.1 Datei-Austausch
Die saubere Phase-1-Version von `BookingImport.php` wurde aus Commit `5a74789`
exportiert und liegt unter:
```
dev/hotfix-2026-04-18/BookingImport.php
```
Upload:
```bash
scp dev/hotfix-2026-04-18/BookingImport.php \
user@live:/var/www/html/app/Services/BookingImport.php
# Auf Live:
php artisan cache:clear
php artisan view:clear
php artisan config:clear
composer dump-autoload
```
### 4.2 DB-Reparatur für bereits verwaiste Buchungen
Vorschau-Query:
```sql
SELECT COUNT(*) FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17';
SELECT b.id AS booking_id, b.customer_id, b.created_at AS booking_created,
l.id AS lead_candidate_id, l.created_at AS lead_created,
c.firstname, c.name, c.email
FROM booking b
LEFT JOIN lead l ON l.customer_id = b.customer_id
LEFT JOIN customer c ON c.id = b.customer_id
WHERE b.lead_id IS NULL
AND b.created_at >= '2026-04-17'
ORDER BY b.id DESC;
-- Sanity-Check: eindeutige 1:1-Zuordnung Customer ↔ Lead?
SELECT customer_id, COUNT(*) AS lead_count
FROM lead
WHERE customer_id IN (
SELECT customer_id FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17'
)
GROUP BY customer_id
HAVING lead_count > 1;
```
Reparatur (nur wenn Sanity-Check leer):
```sql
UPDATE booking b
INNER JOIN lead l ON l.customer_id = b.customer_id
SET b.lead_id = l.id
WHERE b.lead_id IS NULL
AND b.created_at >= '2026-04-17';
```
Zwei betroffene Buchungen wurden am 2026-04-18 so manuell nachgetragen (vom User).
## 5. Reparatur-Toolkit für zukünftige Ausfälle
Zwei Artisan-Commands wurden gebaut, um ähnliche Import-Probleme gezielt
abhandeln zu können (siehe `app/Console/Commands/`). Beide sind **nicht Teil
von Phase 2** — reine Live-Werkzeuge, die sowohl auf Phase 1 als auch auf
Phase 2 funktionieren (sie nutzen das bestehende `BookingImport`).
### 5.1 `bookings:resync-from-travel-bookings`
**Use Case:** `travel_booking.crm_booking_id IS NULL` — Import ist nie
durchgelaufen oder mittendrin abgebrochen. Zieht das komplette Booking
(Customer + Lead + Booking + Participants + Arrangements + ServiceItems +
Drafts) frisch nach.
```bash
# Dry-Run (immer zuerst):
php artisan bookings:resync-from-travel-bookings --days=14
# Mit Anwenden:
php artisan bookings:resync-from-travel-bookings --days=14 --apply
# Einzelfall:
php artisan bookings:resync-from-travel-bookings --id=12345 --apply
# Plus Orphan-Check:
php artisan bookings:resync-from-travel-bookings --days=30 --include-orphans
```
Jeder Import in eigener Transaktion — Fehler in einem Datensatz stoppt die
anderen nicht.
### 5.2 `bookings:repair-from-travel-bookings`
**Use Case:** `crm_booking_id` ist gesetzt, aber einzelne Programm-Felder im
Booking fehlen (z. B. `title`, `travel_number`, `travelagenda_id`). Macht nur
ein Partial-Update der sechs Programm-Felder.
| booking-Spalte | Quelle in `travel_booking.selected_travel` |
|---|---|
| `title` | `travel_title` |
| `travel_number` | `travel_number` |
| `travel_country_id` | `travel_country_id[0]` |
| `travel_category_id` | `travel_category_id` |
| `travelagenda_id` | `travelagenda_id` |
| `travel_company_id` | `travel_company_id` |
```bash
# Dry-Run:
php artisan bookings:repair-from-travel-bookings --days=14
# Anwenden:
php artisan bookings:repair-from-travel-bookings --days=14 --apply
# Gezielt auf CRM-Booking-ID:
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply
# Auch bereits gesetzte Felder überschreiben:
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply --force
```
Default-Policy: **nur leere/NULL-Felder füllen**. Manuell gesetzte Werte
bleiben unangetastet. Mit `--force` werden auch bestehende Werte überschrieben.
### 5.3 Entscheidungshilfe
| Situation | Tool |
|---|---|
| `travel_booking.crm_booking_id IS NULL` | `bookings:resync-from-travel-bookings` |
| Buchung existiert, nur Programm-Felder leer | `bookings:repair-from-travel-bookings` |
| Buchung existiert, aber viele Felder / Teilnehmer fehlen | CRM-Booking löschen → `travel_booking.crm_booking_id` auf NULL → `bookings:resync-…` |
## 6. Deploy dieser Hotfix-Tools auf Live
Beide Commands sind selbst-enthaltene PHP-Dateien, keine DB-Änderung nötig:
```bash
scp app/Console/Commands/BookingsResyncFromTravelBookings.php \
user@live:/var/www/html/app/Console/Commands/BookingsResyncFromTravelBookings.php
scp app/Console/Commands/BookingsRepairFromTravelBookings.php \
user@live:/var/www/html/app/Console/Commands/BookingsRepairFromTravelBookings.php
# Auf Live:
php artisan list | grep "bookings:"
# → muss beide Commands zeigen
```
## 7. Konsequenzen für Phase-2-Deploy
Dieser Vorfall hat gezeigt, dass beim Phase-1-Deploy am 2026-04-17 mindestens
eine Phase-2-Datei mit rübergerutscht ist. Vor Phase-2-Deploy MUSS daher
zusätzlich zum bestehenden `UPLOAD-LIST`-Verfahren ein expliziter Scan
stattfinden — siehe Ergänzung im Phase-2-Deploy-Handbuch.
**Sanity-Check vor jedem zukünftigen Phase-Deploy:**
```bash
# Auf Live, nach dem Upload:
grep -rn "inquiry_id\|'contacts'\b\|'inquiries'\b" app/ 2>/dev/null | head -40
```
Wenn nach Phase-2-Deploy alle Treffer auf die erwarteten Stellen zeigen (und
nicht auf nicht-gelistete Dateien), ist der Deploy sauber. Ein analoger
Gegencheck vor Phase 2 (dort dürfen keine Phase-2-Tokens existieren außer
in `BookingImport.php`, falls dieser Hotfix-Stand noch nicht ersetzt wurde).
---
## Anhang: Inhalt dieses Ordners
```
dev/hotfix-2026-04-18/
├── HOTFIX.md ← Dieses Dokument
└── BookingImport.php ← Phase-1-Version aus Commit 5a74789 (Deploy-Ready)
```
Die beiden neuen Artisan-Commands liegen im normalen Code-Baum:
```
app/Console/Commands/BookingsResyncFromTravelBookings.php
app/Console/Commands/BookingsRepairFromTravelBookings.php
```
Sie gehören dauerhaft zum Projekt und sollen auch nach Phase-2-Deploy
bestehen bleiben (sie funktionieren auf beiden Schemas).

View file

@ -1,16 +1,127 @@
# Modul 6 — Angebote: Implementierungs-Tickets
**Status:** Entwurf, bereit zur Umsetzung
**Status:** In Umsetzung (A7 abgeschlossen, Phase B als Nächstes)
**Erstellt:** April 2026
**Letzte Aktualisierung:** 2026-04-24
**Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md)
**Abhängigkeiten-Check vor Start:**
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren.
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`.
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren. ✅ Test
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`. ⬜ Fallback gewählt
- Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen.
- Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar.
---
## Fortschritt
| Ticket | Status | Datum | Notizen |
|--------|--------|-------|---------|
| A1 — Datenbank-Migrationen (7 Files) | ✅ Test (Batch 34) | 2026-04-18 | FK-Typ-Fix nötig, siehe A1-Lessons |
| A2 — Eloquent-Models (6 Files) | ✅ Test | 2026-04-18 | Smoke-Test grün, siehe A2-Lessons |
| A3 — Repositories (4 Files) | ✅ Test | 2026-04-18 | Smoke-Test 9/9 grün, siehe A3-Lessons |
| A4 — OfferService + Exceptions + Events | ✅ Test | 2026-04-18 | Smoke-Test 13/13 grün, siehe A4-Lessons |
| A5 — FormRequests (4 Files) | ✅ | 2026-04-18 | `app/Http/Requests/Offer/*`, siehe A5-Lessons |
| A6 — Routing + Permissions + Sidenav | ✅ | 2026-04-24 | 15 Routen, 5 Rechte, siehe A6-Lessons |
| A7 — Factories + Seeder | ✅ | 2026-04-24 | `ContactFactory` + 4 Offer-Factories + `OfferTemplateSeeder`, siehe A7-Lessons |
### A1 — Lessons Learned (Test-Migration 2026-04-18)
1. **FK-Typ-Mismatch:** Spec/erste Version nutzte `foreignId(...)``bigint unsigned`. Die Legacy-Tabellen haben aber:
- `contacts.id`, `inquiries.id`, `booking.id`, `branch.id``bigint` **(signed!)**
- `users.id``int unsigned`
Fix in 3 Migrationen (100001, 100002, 100004): explizit `$t->bigInteger('contact_id')` etc. + manueller `$t->foreign(...)->references('id')->on('xxx')`. Für `users``unsignedInteger('created_by')`.
2. **Migrations-Pfad-Trick:** Das Projekt hat viele alte Pending-Migrationen, deren Tabellen längst existieren (Sequel-Pro-Export-Historie). `php artisan migrate --force` (ohne Pfad) schlägt deswegen fehl. Workaround:
```bash
mkdir -p storage/migrations-tmp/offers
for f in database/migrations/2026_04_17_*.php; do
ln -sf "$(realpath $f)" "storage/migrations-tmp/offers/$(basename $f)"
done
php artisan migrate --path=storage/migrations-tmp/offers --realpath --force
```
So werden ausschließlich die 7 Offer-Files geladen.
3. **Class-Name-Bug entdeckt & gefixt:** `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php` deklarierte fälschlicherweise `class CreateBookingVoucherTable` (richtig: `CreateBookingVoucherAgencyTable`). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen.
4. **Verifikation OK:** 15 neue FKs gesetzt (5 auf `offers`, 3 auf `offer_versions`, je 12 auf andere + 1 Reverse auf `booking.offer_id`), Indizes/Unique-Constraints alle vorhanden. Siehe `SHOW CREATE TABLE`-Outputs in den Smoke-Tests.
5. **Akzeptanzkriterium „migrate:fresh --seed reproduzierbar":** Auf Test nicht ausführbar (würde DB löschen). Empfehlung: in CI auf einer Throwaway-DB testen, sobald CI-Pipeline existiert.
### Live-Deploy von A1 (TODO später)
Wenn A2-A7 fertig sind und das Modul deploybar ist, müssen für Live mit:
- 7 Migrationen aus `database/migrations/2026_04_17_100001` bis `100007`
- 1 Klassennamen-Fix aus `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php`
ein eigenes Live-Deploy-Handbuch geschrieben werden (analog `phase-2-live-deploy.md`).
### A2 — Lessons Learned (2026-04-18)
1. **6 Models angelegt**: `Offer`, `OfferVersion`, `OfferItem`, `OfferTemplate`, `OfferFile`, `OfferAccessToken` in `app/Models/`. Alle mit `HasFactory`, `$connection='mysql'`, typisierten PHPDoc-Annotations, `STATUS_*`-Konstanten (statt String-Literals) und sauberen `BelongsTo`/`HasMany`-Relations.
2. **Status-Konstanten:** Stati sind als `self::STATUS_DRAFT` etc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl. `superseded`). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet und `whereIn` mit Strings gut funktioniert.
3. **OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self** erzeugt `Str::random(64)`-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Property `plain_token`. Counterpart `findActiveByPlainToken(string)` hasht den eingehenden Klartext und scoped auf `active()` (nicht widerrufen, nicht abgelaufen).
4. **OfferFile API-kompatibel zu BookingFile**: Gleiche Method-Signaturen (`getURL`, `getPath`, `getIconExt`, `formatBytes`) — so können Blade-Partials/Dropzone-Helper aus dem Booking-Modul geteilt werden. Unterschied: Spalte `include_in_pdf` statt `frozen` (semantisch klarer).
5. **Smoke-Test (Tinker, in Transaktion + Rollback)**: Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading `currentVersion.items / .tokens / contact` grün, `totalPriceFormatted() → "1.234,56 €"`, `isEditable() → true` für Draft.
6. **Offer-Inquiry-Relation zeigt auf Lead-Model**: Die Tabelle heißt nach Phase 2 `inquiries`, das Eloquent-Model ist aber weiterhin `App\Models\Lead` (Umbenennung des Models ist eigenes Ticket). Offer verwendet `$this->belongsTo(Lead::class, 'inquiry_id')`.
### A3 — Lessons Learned (2026-04-18)
1. **4 Repositories angelegt**: `OfferRepository`, `OfferVersionRepository`, `OfferTemplateRepository` (extends `BaseRepository`) und `OfferFileRepository` (extends `FileRepository`). Alle mit `use Illuminate\Support\Facades\DB;` — konsistent mit Phase-1-Stil der `ContactsMergeDuplicates`-Command.
2. **`generateOfferNumber()` race-safe umgesetzt**: `lockForUpdate()` auf der höchsten `offer_number LIKE '{year}-%'`-Zeile innerhalb einer `DB::transaction`; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordert `YYYY-NNNNN`.
3. **`OfferRepository::create()` atomar**: Offer + Version#1 in einem `DB::transaction`-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.
4. **`createNewVersion()` dupliziert via `replicate()`**: Methode nutzt Eloquents `replicate(['except-cols'])`, um Items + Files als neue Zeilen zu kopieren. Physische Datei-Binaries werden **nicht** dupliziert — die Datei-Zeile bekommt nur eine neue DB-ID. Falls später einzelne Versionen gelöscht werden, muss die Delete-Logik prüfen, ob andere Versionen noch auf dieselbe Datei referenzieren (Aufgabe für B4/B5).
5. **`updateDraft()` wirft `DomainException` bei eingefrorener Version**: Smoke-Test 7 bestätigt das saubere Verhalten. Item-Sync ist destruktiv: IDs aus `$data['items']` werden aktualisiert, fehlende IDs neu angelegt, nicht mehr enthaltene Items gelöscht. `total_price` wird danach aus `SUM(items.total_price)` neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.
6. **`OfferTemplate::applyTo()` ist behutsam**: Überschreibt nur **leere** Text-Felder (`?: $template->default_xxx`); Items der Version werden hingegen komplett ersetzt (das ist der Sinn einer Vorlage). Property-Access-Warnungen für globale `now()`/`\Storage`-Facades haben wir mit `Carbon::now()` bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.
7. **Smoke-Test 9/9 grün**: Nummer generieren / atomares create / findByNumber / updateDraft mit Items (Summe 5.400 €) / Item-Sync (5.278 €) / markSent (+ Offer-Status auf sent) / updateDraft auf Sent wirft korrekt / createNewVersion (V1→superseded, V2 mit 3 kopierten Items, current_version umgeschoben, Offer zurück auf draft) / Template-anwenden (leere Texte übernommen, bestehender headline geschützt, 2 neue Items, Summe 2.119 €). Alles in einer Transaktion + Rollback.
### A4 — Lessons Learned (2026-04-18)
1. **`OfferService` mit DI-Konstruktor** (4 Repos injected). Kein Static-Helper wie `App\Services\Booking` — der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.
2. **2 Custom Exceptions, sprechend:**
- `OfferNotEditableException(OfferVersion $v, string $action)` — trägt `versionId` + `currentStatus` als readonly Properties, Controller können fein unterscheiden.
- `InvalidOfferStatusException(Offer $o, array $expected, string $action)` — trägt `offerId`, `currentStatus`, `expectedStatuses[]`.
3. **4 Domain-Events** (`OfferSent`, `OfferAccepted`, `OfferDeclined`, `OfferWithdrawn`) in `app/Events/Offer/`. Dispatchen per `Event::dispatch()` — die Mail/Audit-Log-Arbeit machen Listener in Ticket B6 und später Modul 3 Phase 4 (Communications). Der Service selbst weiß nichts von Mail oder `customer_mails`.
4. **`send()` isoliert den Token-Lifecycle**: Erzeugt frischen Access-Token in derselben Transaktion wie den Status-Wechsel. Falls später die Mail scheitert, wird der Event-Listener die Queue-Retry machen — der Status-Übergang bleibt gültig. Guard: nur auf der `current_version_id`-Version, nur im Status `draft`.
5. **`supersede()` revoked alte Tokens**: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Call `revoked_at IS NOT NULL` hat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.
6. **`markDeclined()` akzeptiert `sent` UND `accepted`**: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend auf `declined` gesetzt.
7. **`purgeExpired()` respektiert Offer-Hierarchie**: Setzt nur dann auch den Offer auf `expired`, wenn der Offer (a) noch `sent` ist und (b) die expirierte Version die `current_version_id` ist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).
8. **`convertToBooking()` nur als Guards+Stub**: Wirft sauber `InvalidOfferStatusException` (not accepted) und `DomainException` (double-invoke), aber der eigentliche Booking-Create hängt an `BookingService::createManual()` aus Modul 4 und wird in Ticket B8 finalisiert.
9. **Smoke-Test 13/13 grün**: createBlank + createBlank-mit-Template / updateVersion / send+Token / 2× Editable-Guard (updateVersion & send auf sent) / markAccepted + Double-Accept-Guard / supersede+Token-Revoke / withdraw + Double-Withdraw-Guard / convertToBooking-Guard / purgeExpired — alles in Transaktion + Rollback.
10. **Echte PHPUnit-Unit-Tests (Akzeptanzkriterium aus Spec) werden in Ticket F4 angelegt** (dort ist Coverage-Ziel ≥ 80 % für Service+Repo vereinbart). Der aktuelle Tinker-Smoke-Test liefert aber alle Pfad-Abdeckungen als Runbook, die F4 als Vorlage nutzen kann.
### A5 — Lessons Learned (2026-04-18)
1. **Erste `FormRequest`s im CRM-Projekt** (vorher keine `app/Http/Requests/`-Klasse). Muster: `Illuminate\Foundation\Http\FormRequest`, `authorize() =>` eingeloggter User, `rules()`, ggf. `withValidator` / `prepareForValidation`.
2. **StoreOfferRequest — Kreuz-Validierung**: Mindestens eines von `contact_id` / `inquiry_id` muss gesetzt sein (`withValidator` + Fehler auf `contact_id`). `template_id` mit `Rule::exists('offer_templates','id')->whereNull('deleted_at')` (keine gelöschte Vorlage).
3. **UpdateVersionRequest — WYSIWYG**: `intro_text` / `itinerary_text` / `closing_text` mit `strip_tags` + Whitelist; `headline` ohne Whitelist. Kein Mews/HtmlPurifier in `composer.json` — bei Bedarf nachziehen.
4. **Preise in Positionen**: `items.*.price_per_unit` = `numeric`; in `withValidator` für Typen außer `discount` Wert `>= 0` erzwingen.
5. **valid_until**`after_or_equal:today` (nicht in der Vergangenheit). **SendOfferRequest** `expires_at``after:now` (für E-Mail-Token echter Zeitpunkt in der Zukunft).
6. **cc/bcc** als langer String; Aufteilen in einzelne Adressen erst im Mail-Listener (B6).
7. **Datei-Upload-Regeln (MIME wie BookingFile)** verbleiben am Upload-Pfad (wie `FileRepository` bei Buchungen) — in A6 kann bei Bedarf ein `UploadOfferFileRequest` ergänzt werden.
### A6 — Lessons Learned (2026-04-24)
1. **Routen im bestehenden `admin`+`2fa`-Block** (nicht `auth.2fa` der Spec — im Projekt heißt es `2fa` + `AdminMiddleware`). Permission-Gates mit `auth.permission:…` in drei Gruppen: `offers-r` (lesen + DataTable + `GET offer/action` + PDF-Stub), `offers-w` (POST detail, Löschen, File-Upload/Delete), `offer-templates-w` (Vorlagen-CRUD).
2. **5 neue Keys in `config/permissions.php`:** `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w`. Bestehende User haben die Keys **nicht automatisch** in `users.permissions` — nach Deploy im User-Rechte-UI setzen (SuperAdmin) oder JSON per SQL ergänzen; bei `setPermissionsDefault()` greifen sie nur, wenn `permissions` leer/ungültig ist.
3. **Fein-Permissions in `OfferController@action`:** pro `action` werden `offers-w` (Mutation), `offers-send` (send/resend), `offers-accept` (Zusage/Absage/withdraw) geprüft; unbekannte Aktionen verlangen `offers-w`.
4. **Menü** `layout-sidenav` zwischen „Anfragen“ und „Kunden“: sichtbar bei `offers-r` **oder** `offers-w` (nur-Writer-Edge-Case), Icon `ion-md-document`, aktive Pfade `offers` / `offer/*`.
5. **Stub-UI:** `resources/views/offer/index.blade.php` mit server-side DataTable (`data_table_offers`); `detail` + Vorlagen-Views + PDF sind Platzhalter für B1/B3/B5/C1.
6. **Disk `offer`** war bereits in `config/filesystems.php``OfferFileController` setzt `disk=offer` wie beim Booking-Upload.
### A7 — Lessons Learned (2026-04-24)
1. **`ContactFactory` ergänzt:** `Offer` verlangt `contact_id`; ohne Factory müsste jeder Test-Kontakt manuell angelegt werden. `Contact` hatte bereits `HasFactory`, es fehlte nur die Klassendatei unter `database/factories/`.
2. **`OfferFactory` + V1 ohne Rekursion:** Erste `OfferVersion` wird in `OfferFactory::afterCreating()` per `OfferVersion::query()->create()` angelegt und `current_version_id` gesetzt — kein `OfferVersion::factory()` mit verschachteltem `Offer::factory()` (unique `(offer_id, version_no)` und zyklische FKs).
3. **`OfferVersionFactory`:** Standalone-`definition()` nutzt `offer_id``Offer::factory()` und **`version_no` = 2**, weil `Offer::factory()` automatisch V1 erzeugt. Für manuelle Tests: `forOffer($offer, $n)` mit frei wählbarer Versionsnummer.
4. **States:** `Offer::factory()->sent()` / `->accepted()` setzen Offer-Status und synchronisieren die **aktuelle** Version (`refresh()->currentVersion`). `OfferVersionFactory` hat zusätzlich `versionSent()` / `versionAccepted()` für reine Versions-Snapshots.
5. **`OfferItemFactory`:** Hilfsmethoden `forCurrentVersionOf(Offer)` und `forVersion(OfferVersion)`; `asDiscount()` für Rabattzeilen. Erste Position nutzt `OfferItem::TYPE_TRAVEL`.
6. **`OfferTemplateSeeder`:** Fünf realistische Vorlagen per `updateOrCreate(['name' => …])`; `created_by` = kleinster existierender `users.id` (ohne `User::factory()` im Seeder, damit lokale/Stage-Seeds keinen Blind-User erzeugen). Leere User-Tabelle → Seeder bricht ab mit Warnung. Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`.
7. **Smoke-Test:** An dieser Umgebung war kein MySQL-Hostname erreichbar (`getaddrinfo for global-mysql`); Syntax-Check `php -l` grün. Lokal/CI mit laufender DB: `Offer::factory()->create()` + `OfferItem::factory()->forCurrentVersionOf($o)`.
---
## 0. Übersicht
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **79 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
@ -375,12 +486,14 @@ Route::group(['middleware' => ['auth.2fa']], function () {
**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 alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an.
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.
---