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

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