phase 2 dev

This commit is contained in:
Kevin Adametz 2026-04-22 16:01:27 +02:00
parent 5a7478907e
commit ba48745809
59 changed files with 2692 additions and 1994 deletions

View file

@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Findet Buchungen, bei denen nach Storno price_total nicht mehr zu price_canceled passt
* (typisch nach Speichern / calculate_price_total ohne Storno-Schutz).
*
* php artisan bookings:audit-storno-price-total
* php artisan bookings:audit-storno-price-total --export=/tmp/mismatch.csv
* php artisan bookings:audit-storno-price-total --fix (setzt price_total = price_canceled)
*/
class BookingsAuditStornoPriceTotal extends Command
{
protected $signature = 'bookings:audit-storno-price-total
{--fix : Setzt price_total auf price_canceled für betroffene Zeilen}
{--export= : CSV-Datei (relativ zum Projektroot oder absolut)}
{--id=* : Nur diese Buchungs-IDs (booking.id)}';
protected $description = 'Abgleich Storno: price_total vs. price_canceled (inkl. optional --fix / --export)';
private const EPS = 0.009;
public function handle(): int
{
$query = Booking::query()
->whereNotNull('canceled')
->whereNotNull('price_canceled')
->orderBy('id');
$ids = $this->option('id');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$mismatch = [];
$fixable = [];
foreach ($query->cursor() as $booking) {
$pt = (float) $booking->getPriceTotalRaw();
$pc = (float) $booking->getPriceCanceledRaw();
if (abs($pt - $pc) <= self::EPS) {
continue;
}
$hasStornoRow = $booking->booking_strono()->exists();
$hasStornoDoc = $booking->hasDocument('storno');
$mismatch[] = [
'id' => $booking->id,
'merlin_order_number' => $booking->merlin_order_number,
'price' => $booking->getPriceRaw(),
'price_canceled' => $pc,
'price_total_db' => $pt,
'price_balance' => $booking->getPriceBalanceRaw(),
'canceled_pct' => $booking->getCanceledRaw(),
'booking_storno_row' => $hasStornoRow ? 'ja' : 'nein',
'storno_pdf' => $hasStornoDoc ? 'ja' : 'nein',
'diff' => round($pt - $pc, 2),
];
$fixable[] = $booking->id;
}
$this->warn('Storno mit gesetztem price_canceled, aber price_total weicht ab:');
$this->newLine();
if ($mismatch === []) {
$this->info('Keine Abweichungen gefunden.');
} else {
$this->table(
['id', 'MyJack', 'price', 'price_canceled', 'price_total', 'diff', 'storno_row', 'storno_pdf'],
collect($mismatch)->map(function ($r) {
return [
$r['id'],
$r['merlin_order_number'],
$r['price'],
$r['price_canceled'],
$r['price_total_db'],
$r['diff'],
$r['booking_storno_row'],
$r['storno_pdf'],
];
})->all()
);
$this->newLine();
$this->line('Anzahl: '.count($mismatch));
}
$exportPath = $this->option('export');
if ($exportPath && $mismatch !== []) {
$path = $this->resolveExportPath($exportPath);
$fp = fopen($path, 'w');
if ($fp === false) {
$this->error('Export nicht schreibbar: '.$path);
return self::FAILURE;
}
fputcsv($fp, array_keys($mismatch[0]), ';');
foreach ($mismatch as $row) {
fputcsv($fp, $row, ';');
}
fclose($fp);
$this->info('CSV geschrieben: '.$path);
}
if ($this->option('fix')) {
if ($fixable === []) {
$this->info('Nichts zu korrigieren.');
return self::SUCCESS;
}
if (! $this->confirm('price_total für '.count($fixable).' Buchung(en) auf price_canceled setzen?', true)) {
$this->warn('Abgebrochen.');
return self::SUCCESS;
}
$updated = 0;
DB::transaction(function () use ($fixable, &$updated) {
foreach ($fixable as $id) {
$b = Booking::query()->lockForUpdate()->find($id);
if (! $b) {
continue;
}
$pc = (float) $b->getPriceCanceledRaw();
$pt = (float) $b->getPriceTotalRaw();
if (abs($pt - $pc) <= self::EPS) {
continue;
}
$b->price_total = round($pc, 2);
$b->save();
$updated++;
}
});
$this->info("Korrigiert: {$updated} Buchung(en).");
}
$this->newLine();
$this->line('Hinweis: Buchungen mit canceled gesetzt, aber price_canceled NULL, werden hier nicht geprüft.');
return self::SUCCESS;
}
private function resolveExportPath(string $exportPath): string
{
if ($exportPath[0] === '/' || preg_match('#^[A-Za-z]:\\\\#', $exportPath)) {
return $exportPath;
}
return base_path(trim($exportPath, '/'));
}
}

View file

@ -37,7 +37,7 @@ class ContactsFindDuplicates extends Command
// ── HIGH: gleiche E-Mail ──────────────────────────────────────────
if ($this->shouldCheck('HIGH')) {
$emailDupes = DB::table('customer')
$emailDupes = DB::table('contacts')
->select('email', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids'))
->whereNotNull('email')
->where('email', '!=', '')
@ -60,7 +60,7 @@ class ContactsFindDuplicates extends Command
// ── MEDIUM: Name + Vorname + Geburtsdatum ────────────────────────
if ($this->shouldCheck('MEDIUM')) {
$nameBdDupes = DB::table('customer')
$nameBdDupes = DB::table('contacts')
->select(
'name', 'firstname', 'birthdate',
DB::raw('COUNT(*) as cnt'),
@ -88,7 +88,7 @@ class ContactsFindDuplicates extends Command
// ── LOW: Name + Vorname + PLZ ─────────────────────────────────────
if ($this->shouldCheck('LOW')) {
$nameZipDupes = DB::table('customer')
$nameZipDupes = DB::table('contacts')
->select(
'name', 'firstname', 'zip',
DB::raw('COUNT(*) as cnt'),

View file

@ -90,7 +90,7 @@ class ContactsMergeDuplicates extends Command
private function findByEmail(): array
{
return DB::table('customer')
return DB::table('contacts')
->select('email', DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('email')
->where('email', '!=', '')
@ -104,7 +104,7 @@ class ContactsMergeDuplicates extends Command
private function findByNameBirthdate(): array
{
return DB::table('customer')
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
@ -119,7 +119,7 @@ class ContactsMergeDuplicates extends Command
private function findByNameZip(): array
{
return DB::table('customer')
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
@ -173,11 +173,11 @@ class ContactsMergeDuplicates extends Command
private function mergeInto(int $masterId, int $dupeId): void
{
// 1. Leads umhängen
$leadCount = DB::table('lead')->where('customer_id', $dupeId)->count();
$leadCount = DB::table('inquiries')->where('customer_id', $dupeId)->count();
if ($leadCount > 0) {
$this->line(" lead.customer_id: {$leadCount} Zeile(n) → #{$masterId}");
if (!$this->dryRun) {
DB::table('lead')
DB::table('inquiries')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
@ -220,7 +220,7 @@ class ContactsMergeDuplicates extends Command
// 5. Duplikat als zusammengeführt markieren
if (!$this->dryRun) {
DB::table('customer')
DB::table('contacts')
->where('id', $dupeId)
->update([
'merged_into_id' => $masterId,

View file

@ -193,7 +193,8 @@ class SyncNewsletterKulturreisen extends Command
'description' => 'Kontakt durch Kulturreisen-Buchung erstellt',
'metadata' => [
'booking_id' => $booking->id,
'lead_id' => $booking->lead_id,
// Metadaten-Key bleibt `lead_id`; Wert kommt aus booking.inquiry_id.
'lead_id' => $booking->inquiry_id,
],
]);
}

View file

@ -34,7 +34,9 @@ class BookingController extends Controller
$ret = [
'url_v1' => make_old_url('/index.php/booking/' . $booking->id . '/edit'),
'url_v3' => route('booking_detail', $booking->id),
'lead_id' => $booking->lead_id
// API-Feld bleibt `lead_id` aus Abwärtskompatibilität für API-Konsumenten;
// Wert kommt nach Modul 3 Phase 2 aus booking.inquiry_id.
'lead_id' => $booking->inquiry_id
];
return response()->json(['success' => "import", "ret" => $ret], $this->successStatus);

View file

@ -172,10 +172,10 @@ class ReportBookingController extends Controller
->orderColumn('end_date', 'end_date $1')
->orderColumn('price', 'price $1')
->orderColumn('booking_date', 'booking_date $1')
->orderColumn('customer.fullName', 'customer.firstname $1')
->orderColumn('customer.firstname', 'customer.firstname $1')
->orderColumn('customer.name', 'customer.name $1')
//->orderColumn('lead.status_id', 'lead.status_id $1')
->orderColumn('customer.fullName', 'contacts.firstname $1')
->orderColumn('customer.firstname', 'contacts.firstname $1')
->orderColumn('customer.name', 'contacts.name $1')
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
//->orderColumn('is_cleared', 'is_cleared $1')
->rawColumns(['id', 'lead.status_id', 'service_provider.names'])
->make(true);
@ -384,9 +384,9 @@ class ReportBookingController extends Controller
->orderColumn('end_date', 'end_date $1')
->orderColumn('price', 'price $1')
->orderColumn('booking_date', 'booking_date $1')
->orderColumn('customer.firstname', 'customer.firstname $1')
->orderColumn('customer.name', 'customer.name $1')
//->orderColumn('lead.status_id', 'lead.status_id $1')
->orderColumn('customer.firstname', 'contacts.firstname $1')
->orderColumn('customer.name', 'contacts.name $1')
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
//->orderColumn('is_cleared', 'is_cleared $1')
->rawColumns(['id', 'old_crm', 'check_total', 'lead.status_id'])
->make(true);

View file

@ -189,10 +189,10 @@ class ReportController extends Controller
->orderColumn('end_date', 'end_date $1')
->orderColumn('price', 'price $1')
->orderColumn('booking_date', 'booking_date $1')
->orderColumn('customer.fullName', 'customer.firstname $1')
->orderColumn('customer.firstname', 'customer.firstname $1')
->orderColumn('customer.name', 'customer.name $1')
//->orderColumn('lead.status_id', 'lead.status_id $1')
->orderColumn('customer.fullName', 'contacts.firstname $1')
->orderColumn('customer.firstname', 'contacts.firstname $1')
->orderColumn('customer.name', 'contacts.name $1')
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
//->orderColumn('is_cleared', 'is_cleared $1')
->rawColumns(['id', 'lead.status_id', 'service_provider.names'])
->make(true);
@ -400,10 +400,10 @@ class ReportController extends Controller
$price = 0;
$isset = [];
foreach ($all as $v){
if(!in_array($v->booking->lead_id, $isset)){
if(!in_array($v->booking->inquiry_id, $isset)){
$price += $v->booking->isCanceled() ? $v->booking->getPriceCanceledRaw() : $v->booking->getPriceRaw();
}
$isset[] = $v->booking->lead_id;
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($price);
@ -416,8 +416,8 @@ class ReportController extends Controller
$price = 0;
$isset = [];
foreach ($all as $v){
$price += in_array($v->booking->lead_id, $isset) ? 0 : $v->booking->getPriceTotalRaw();
$isset[] = $v->booking->lead_id;
$price += in_array($v->booking->inquiry_id, $isset) ? 0 : $v->booking->getPriceTotalRaw();
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($price);
})
@ -429,8 +429,8 @@ class ReportController extends Controller
$proceeds = 0;
$isset = [];
foreach ($all as $v){
$proceeds += in_array($v->booking->lead_id, $isset) ? 0 : $v->booking->proceeds(true);
$isset[] = $v->booking->lead_id;
$proceeds += in_array($v->booking->inquiry_id, $isset) ? 0 : $v->booking->proceeds(true);
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($proceeds);
/*$all = $query->get();
@ -562,7 +562,7 @@ class ReportController extends Controller
$ctemps[$export->booking->id][] = array(
'Zähler' => $new ? $export->getCounter() : "",
'MyJack Nr.' => $new ? $export->booking->merlin_order_number : "",
'CRM Nr' => $new ? $export->booking->lead_id : "",
'CRM Nr' => $new ? $export->booking->inquiry_id : "",
'Kunde' => $new ? $export->booking->customer->name : "",
'Reisedatum' => $new ? $export->booking->getStartDateFormat() : "",
'Organisation' => $new ? ($export->booking->isCanceled() ? $export->booking->price_canceled : $export->booking->price) : "",
@ -630,7 +630,7 @@ class ReportController extends Controller
$ctemps[$export->booking->id][] = array(
'Zähler' => $new ? $export->getCounter() : "",
'MyJack Nr.' => $new ? $export->booking->merlin_order_number : "",
'CRM Nr' => $new ? $export->booking->lead_id : "",
'CRM Nr' => $new ? $export->booking->inquiry_id : "",
'Kunde' => $new ? $export->booking->customer->name : "",
'Reisedatum' => $new ? $export->booking->getStartDateFormat() : "",
'Reiseland' => $new && $export->booking->travel_country ? $export->booking->travel_country->name : "",
@ -725,9 +725,9 @@ class ReportController extends Controller
->orderColumn('end_date', 'end_date $1')
->orderColumn('price', 'price $1')
->orderColumn('booking_date', 'booking_date $1')
->orderColumn('customer.firstname', 'customer.firstname $1')
->orderColumn('customer.name', 'customer.name $1')
//->orderColumn('lead.status_id', 'lead.status_id $1')
->orderColumn('customer.firstname', 'contacts.firstname $1')
->orderColumn('customer.name', 'contacts.name $1')
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
//->orderColumn('is_cleared', 'is_cleared $1')
->rawColumns(['id', 'old_crm', 'check_total', 'lead.status_id'])
->make(true);

View file

@ -49,7 +49,7 @@ class ReportLeadsController extends Controller
{
$query = Lead::with('customer')->with('sf_guard_user')->with('status')
->select('lead.*');
->select('inquiries.*');
//->where('deleted_at', '=', null);
/*
@ -132,7 +132,7 @@ class ReportLeadsController extends Controller
// $q->select('sent_at')->where('sent_at', DB::raw("(select max('sent_at') customer_mails)")); //)
})->orderBy(
LeadMail::select('sent_at')
->whereColumn('lead_id', 'lead.id')
->whereColumn('lead_id', 'inquiries.id')
->orderBy('sent_at', 'DESC')
->limit(1)
, $order);
@ -161,9 +161,9 @@ class ReportLeadsController extends Controller
$orderByNum = [
0 => 'id',
1 => 'customer_id',
2 => 'customer.firstname',
3 => 'customer.name',
4 => 'customer.email',
2 => 'contacts.firstname',
3 => 'contacts.name',
4 => 'contacts.email',
5 => 'request_date',
6 => 'travel_country',
7 => 'sf_guard_user.last_name',

View file

@ -104,10 +104,10 @@ class ReportProviderController extends Controller
$price = 0;
$isset = [];
foreach ($all as $v){
if(!in_array($v->booking->lead_id, $isset)){
if(!in_array($v->booking->inquiry_id, $isset)){
$price += $v->booking->isCanceled() ? $v->booking->getPriceCanceledRaw() : $v->booking->getPriceRaw();
}
$isset[] = $v->booking->lead_id;
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($price);
@ -120,8 +120,8 @@ class ReportProviderController extends Controller
$price = 0;
$isset = [];
foreach ($all as $v){
$price += in_array($v->booking->lead_id, $isset) ? 0 : $v->booking->getPriceTotalRaw();
$isset[] = $v->booking->lead_id;
$price += in_array($v->booking->inquiry_id, $isset) ? 0 : $v->booking->getPriceTotalRaw();
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($price);
})
@ -133,8 +133,8 @@ class ReportProviderController extends Controller
$proceeds = 0;
$isset = [];
foreach ($all as $v){
$proceeds += in_array($v->booking->lead_id, $isset) ? 0 : $v->booking->proceeds(true);
$isset[] = $v->booking->lead_id;
$proceeds += in_array($v->booking->inquiry_id, $isset) ? 0 : $v->booking->proceeds(true);
$isset[] = $v->booking->inquiry_id;
}
return Util::_number_format($proceeds);
/*$all = $query->get();
@ -272,7 +272,7 @@ class ReportProviderController extends Controller
$ctemps[$export->booking->id][] = array(
'Zähler' => $new ? $export->getCounter() : "",
'MyJack Nr.' => $new ? $export->booking->merlin_order_number : "",
'CRM Nr' => $new ? $export->booking->lead_id : "",
'CRM Nr' => $new ? $export->booking->inquiry_id : "",
'Kunde' => $new ? $export->booking->customer->name : "",
'Reisedatum' => $new ? $export->booking->getStartDateFormat() : "",
'bis' => $new ? $export->booking->getEndDateFormat() : "",
@ -346,7 +346,7 @@ class ReportProviderController extends Controller
$ctemps[$export->booking->id][] = array(
'Zähler' => $new ? $export->getCounter() : "",
'MyJack Nr.' => $new ? $export->booking->merlin_order_number : "",
'CRM Nr' => $new ? $export->booking->lead_id : "",
'CRM Nr' => $new ? $export->booking->inquiry_id : "",
'Kunde' => $new ? $export->booking->customer->name : "",
'Reisedatum' => $new ? $export->booking->getStartDateFormat() : "",
'bis' => $new ? $export->booking->getEndDateFormat() : "",

View file

@ -157,11 +157,11 @@ class ContactController extends Controller
}
DB::transaction(function () use ($masterId, $dupeId) {
DB::table('lead')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
DB::table('inquiries')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
DB::table('booking')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
DB::table('customer_mails')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
DB::table('lead_mails')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
DB::table('customer')->where('id', $dupeId)->update([
DB::table('contacts')->where('id', $dupeId)->update([
'merged_into_id' => $masterId,
'merged_at' => now(),
]);
@ -175,16 +175,16 @@ class ContactController extends Controller
private function countDuplicateGroups(string $type): int
{
return match ($type) {
'email' => DB::table('customer')->selectRaw('COUNT(*) as cnt')->whereNotNull('email')->where('email', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('email')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_birthdate' => DB::table('customer')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_zip' => DB::table('customer')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('zip')->where('zip', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1')->get()->count(),
'email' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('email')->where('email', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('email')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_birthdate' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_zip' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('zip')->where('zip', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1')->get()->count(),
default => 0,
};
}
private function findByEmail(): array
{
return DB::table('customer')
return DB::table('contacts')
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
->whereNotNull('email')->where('email', '!=', '')
->whereNull('merged_into_id')->whereNull('deleted_at')
@ -194,7 +194,7 @@ class ContactController extends Controller
private function findByNameBirthdate(): array
{
return DB::table('customer')
return DB::table('contacts')
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')
->whereNull('merged_into_id')->whereNull('deleted_at')
@ -204,7 +204,7 @@ class ContactController extends Controller
private function findByNameZip(): array
{
return DB::table('customer')
return DB::table('contacts')
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
->whereNotNull('name')->whereNotNull('firstname')
->whereNotNull('zip')->where('zip', '!=', '')
@ -228,8 +228,8 @@ class ContactController extends Controller
// Reihenfolge wichtig: select() zuerst, dann withCount() — sonst überschreibt
// select() die COUNT-Subqueries die withCount() per addSelect() eingetragen hat.
$query = $showDeleted
? Contact::onlyTrashed()->withoutGlobalScope('not_merged')->select('customer.*')->withCount(['leads', 'bookings'])
: Contact::select('customer.*')->withCount(['leads', 'bookings']);
? Contact::onlyTrashed()->withoutGlobalScope('not_merged')->select('contacts.*')->withCount(['leads', 'bookings'])
: Contact::select('contacts.*')->withCount(['leads', 'bookings']);
// Zusatzfilter aus der UI (werden als extra GET-Parameter gesendet)
if ($request->filled('filter_has_leads')) {
@ -271,11 +271,11 @@ class ContactController extends Controller
->addColumn('leads_count', fn(Contact $contact) => $contact->leads_count)
->addColumn('bookings_count', fn(Contact $contact) => $contact->bookings_count)
->addColumn('deleted_at', fn(Contact $contact) => $contact->deleted_at?->format('d.m.Y H:i') ?? '')
->orderColumn('id', 'customer.id $1')
->orderColumn('deleted_at', 'customer.deleted_at $1')
->filterColumn('id', function ($query, $keyword) {
->orderColumn('contacts.id', 'contacts.id $1')
->orderColumn('deleted_at', 'contacts.deleted_at $1')
->filterColumn('contacts.id', function ($query, $keyword) {
if ($keyword !== '') {
$query->where('customer.id', 'LIKE', '%' . $keyword . '%');
$query->where('contacts.id', 'LIKE', '%' . $keyword . '%');
}
})
->filterColumn('name', function ($query, $keyword) {

View file

@ -68,7 +68,7 @@ class CustomerController extends Controller
public function getCustomers()
{
$query = Customer::with('salutation')->select('customer.*');
$query = Customer::with('salutation')->select('contacts.*');
return \DataTables::eloquent($query)
->addColumn('action_edit', function (Customer $customer) {

View file

@ -232,7 +232,7 @@ class LeadController extends Controller
public function getLeads()
{
$query = Lead::with('customer')->with('sf_guard_user')->with('status')->select('lead.*');
$query = Lead::with('customer')->with('sf_guard_user')->with('status')->select('inquiries.*');
return \DataTables::eloquent($query)
->addColumn('action_edit', function (Lead $lead) {
@ -296,7 +296,7 @@ class LeadController extends Controller
// $q->select('sent_at')->where('sent_at', DB::raw("(select max('sent_at') customer_mails)")); //)
})->orderBy(
LeadMail::select('sent_at')
->whereColumn('lead_id', 'lead.id')
->whereColumn('lead_id', 'inquiries.id')
->orderBy('sent_at', 'DESC')
->limit(1)
, $order);

View file

@ -89,7 +89,7 @@ class RequestController extends Controller
wirte old where has state to new has travel_documents
$bs = Booking::whereHas('arrangements', function($q){
$q->where('state', '!=', NULL);
})->where('lead_id', '!=', NULL)->where('new_drafts', 0)->get();
})->where('inquiry_id', '!=', NULL)->where('new_drafts', 0)->get();
foreach ($bs as $b){
$b->travel_documents = true;
@ -101,7 +101,7 @@ class RequestController extends Controller
private function getSearchRequests()
{
$query = Booking::with('lead')->with('customer')->with('customer_mails')->with('customer_mails')->select('booking.*')->where('lead_id', '!=', NULL);
$query = Booking::with('lead')->with('customer')->with('customer_mails')->with('customer_mails')->select('booking.*')->where('inquiry_id', '!=', NULL);
if (Request::get('full_firstname_search') != "") {
$query->whereHas('customer', function ($q) {
@ -241,7 +241,7 @@ class RequestController extends Controller
}
if (Request::get('full_lead_id_search') != "") {
$query->where('lead_id', 'LIKE', '%' . Request::get('full_lead_id_search') . '%');
$query->where('inquiry_id', 'LIKE', '%' . Request::get('full_lead_id_search') . '%');
}
if (Request::get('full_booking_id_search') != "") {
$query->where('id', 'LIKE', '%' . Request::get('full_booking_id_search') . '%');
@ -398,10 +398,10 @@ class RequestController extends Controller
return '<a data-order="' . $booking->id . '" href="' . make_old_url('booking/' . $booking->id . '/edit') . '" data-id="' . $booking->id . '">' . $booking->id . '</a>';
})
->addColumn('action_lead_edit', function (Booking $booking) {
return '<a href="' . route('lead_detail', [$booking->lead_id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
return '<a href="' . route('lead_detail', [$booking->inquiry_id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
})
->addColumn('lead_id', function (Booking $booking) {
return '<a data-order="' . $booking->lead_id . '" href="' . make_old_url('leads/' . $booking->lead_id . '/edit') . '" data-id="' . $booking->lead_id . '">' . $booking->lead_id . '</a>';
return '<a data-order="' . $booking->inquiry_id . '" href="' . make_old_url('leads/' . $booking->inquiry_id . '/edit') . '" data-id="' . $booking->inquiry_id . '">' . $booking->inquiry_id . '</a>';
})
->addColumn('travel_country_id', function (Booking $booking) {
return '<span data-order="' . ($booking->travel_country_id ? $booking->travel_country_id : 0) . '">' . ($booking->travel_country_id ? $booking->travel_country->name : "-") . '</span>';
@ -523,7 +523,7 @@ class RequestController extends Controller
}
})
*/
->orderColumn('lead_id', 'lead_id $1')
->orderColumn('lead_id', 'inquiry_id $1')
->orderColumn('id', 'id $1')
->orderColumn('travel_country_id', 'travel_country_id $1')
->orderColumn('travelagenda_id', 'travelagenda_id $1')

View file

@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
* @property int $id
* @property Carbon $booking_date
* @property int $customer_id
* @property int $lead_id
* @property int $inquiry_id
* @property bool $new_drafts
* @property int $sf_guard_user_id
* @property int $branch_id
@ -212,7 +212,8 @@ class Booking extends Model
protected $casts = [
'customer_id' => 'int',
'lead_id' => 'int',
'inquiry_id' => 'int',
'offer_id' => 'int',
'new_drafts' => 'bool',
'sf_guard_user_id' => 'int',
'branch_id' => 'int',
@ -256,7 +257,8 @@ class Booking extends Model
protected $fillable = [
'booking_date',
'customer_id',
'lead_id',
'inquiry_id',
'offer_id',
'new_drafts',
'sf_guard_user_id',
'branch_id',
@ -393,9 +395,29 @@ class Booking extends Model
return $this->belongsTo(Customer::class);
}
/**
* Lead/Inquiry der Buchung.
* FK-Spalte `inquiry_id` (vormals `lead_id` Modul 3 Phase 2 Rename).
* Methodenname bleibt `lead()` für Legacy-Kompatibilität; {@see self::inquiry()}
* ist der fachlich korrekte Alias und sollte in neuem Code verwendet werden.
*/
public function lead()
{
return $this->belongsTo(Lead::class);
return $this->belongsTo(Lead::class, 'inquiry_id');
}
public function inquiry()
{
return $this->belongsTo(Lead::class, 'inquiry_id');
}
/**
* Angebot, aus dem diese Buchung entstanden ist (Modul 6, Ticket B8).
* Nullable nicht jede Buchung hat einen Angebots-Vorlauf.
*/
public function offer()
{
return $this->belongsTo(\App\Models\Offer::class);
}
public function sf_guard_user()
@ -765,8 +787,6 @@ class Booking extends Model
$total_children += $prices['children'];
}
if ($travel_draft_item) {
$travel_draft_item->setPriceAdultRaw($travel_price_adult);
$travel_draft_item->setPriceChildrenRaw($travel_price_children);
@ -775,10 +795,23 @@ class Booking extends Model
$travel_draft_item->save();
}
$this->price = $total_adult + $total_children;
$this->price_total = $this->getPriceRaw() + $this->getServiceTotal(true);
$this->setPriceTotalForCurrentState();
$this->save();
}
/**
* Gesamtpreis Reise (price_total): bei Storno mit gesetztem Storno-Betrag = price_canceled
* (wie nach createPDF_Storno in BookingPDFRepository), sonst Reisepreis + Vermittlung.
*/
public function setPriceTotalForCurrentState(): void
{
if ($this->isCanceled() && $this->attributes['price_canceled'] !== null) {
$this->price_total = round((float) $this->getPriceCanceledRaw(), 2);
return;
}
$this->price_total = round((float) $this->getPriceRaw() + (float) $this->getServiceTotal(true), 2);
}
public function getPriceAttribute()
{
return Util::_number_format($this->attributes['price']);

View file

@ -18,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* - merged_into_id + merged_at in $fillable
* - mergedInto() / mergedContacts() Beziehungen
*
* Tabellen-Name: 'customer' (wird in Phase 2 in 'contacts' umbenannt).
* Tabellen-Name: 'contacts' (nach Phase 2 RENAME TABLE customer contacts).
*
* @property int $id
* @property int|null $salutation_id
@ -50,7 +50,7 @@ class Contact extends Model
protected $connection = 'mysql';
protected $table = 'customer';
protected $table = 'contacts';
protected $casts = [
'salutation_id' => 'int',

View file

@ -88,7 +88,12 @@ class Customer extends Model
protected $connection = 'mysql';
protected $table = 'customer';
/**
* Modul 3 Phase 2: customer contacts (RENAME TABLE).
* Der Model-Name bleibt aus Kompatibilität zum Legacy-Code bestehen;
* für die Neuimplementierung steht {@see Contact} bereit.
*/
protected $table = 'contacts';
protected $casts = [
'salutation_id' => 'int',

View file

@ -112,7 +112,12 @@ class Lead extends Model
protected $connection = 'mysql';
protected $table = 'lead';
/**
* Modul 3 Phase 2: lead inquiries (RENAME TABLE).
* Model-Name bleibt (um Breaking Changes in der gesamten Codebase zu vermeiden);
* fachlich ist das Modell jetzt eine "Inquiry" (Anfrage).
*/
protected $table = 'inquiries';
protected $casts = [
'customer_id' => 'int',
@ -167,20 +172,20 @@ class Lead extends Model
protected $passolutionPDFs = [];
public static $lead_mail_dirs = [
11 => ['name' => 'Entwürfe', 'icon'=>'ion-md-create'],
12 => ['name' => 'Papierkorb', 'icon'=>'ion-md-trash'],
11 => ['name' => 'Entwürfe', 'icon' => 'ion-md-create'],
12 => ['name' => 'Papierkorb', 'icon' => 'ion-md-trash'],
];
public function updateNextDueDate($date = false){
public function updateNextDueDate($date = false)
{
if(!$date){
if (!$date) {
$carbon = Carbon::now();
}else{
} else {
$carbon = Carbon::parse($date);
}
$this->next_due_date = $carbon->modify('+ '.$this->status->handling_days.' days')->format("Y-m-d");
$this->next_due_date = $carbon->modify('+ ' . $this->status->handling_days . ' days')->format("Y-m-d");
$this->save();
}
public function customer()
{
@ -243,7 +248,8 @@ class Lead extends Model
public function bookings()
{
return $this->hasMany(Booking::class);
// Modul 3 Phase 2: FK heißt jetzt inquiry_id (vormals lead_id)
return $this->hasMany(Booking::class, 'inquiry_id');
}
public function inquiries()
@ -293,28 +299,31 @@ class Lead extends Model
}
public static function getSfGuardUserArray(){
public static function getSfGuardUserArray()
{
return SfGuardUser::where('is_active', 1)->get()->pluck('fullname', 'id');
}
public static function getTravelCountryArray($emtpy = false){
public static function getTravelCountryArray($emtpy = false)
{
$TravelCountry = TravelCountry::where('active_backend', 1)->orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelCountry->prepend('-', 0) : $TravelCountry;
}
public static function getTravelCategoryArray($emtpy = false){
public static function getTravelCategoryArray($emtpy = false)
{
$TravelCategory = TravelCategory::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelCategory->prepend('-', 0) : $TravelCategory;
}
public static function getTravelAgendaArray($emtpy = false){
public static function getTravelAgendaArray($emtpy = false)
{
$TravelAgenda = TravelAgenda::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelAgenda->prepend('-', 0) : $TravelAgenda;
}
public static function getStatusArray($emtpy = false){
public static function getStatusArray($emtpy = false)
{
$Status = Status::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $Status->prepend('-', 0) : $Status;
}
@ -322,65 +331,68 @@ class Lead extends Model
public function getStatusBadge($booking = null)
{
if($this->status_id && $this->status){
if ($this->status_id && $this->status) {
$color = $this->status->color;
$icon = "";
if($this->status_id == 14 && $this->is_rebook){
if ($this->status_id == 14 && $this->is_rebook) {
$color = '#94ae59';
$icon = '<i class="fa fa-check-circle"></i> ';
}
if($this->status_id == 14 && !$this->is_rebook){
if ($this->status_id == 14 && !$this->is_rebook) {
$icon = '<i class="fa fa-times-circle"></i> ';
}
if($this->status_id == 15){
if ($this->status_id == 15) {
$icon = '<i class="fa fa-balance-scale"></i> ';
if($booking && $booking->lawyer_date){
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$booking->lawyer_date->format('d.m.Y').'</span></span>';
if ($booking && $booking->lawyer_date) {
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $booking->lawyer_date->format('d.m.Y') . '</span></span>';
}
}
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$this->status->name.'</span></span>';
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $this->status->name . '</span></span>';
}
return '<span data-order="0">-</span>';
}
public function getTravelCountryDestco($badge = true){
public function getTravelCountryDestco($badge = true)
{
$out = "";
if($this->bookings->count()){
if ($this->bookings->count()) {
$out .= $badge ? '<span class="badge badge-success">' : '';
foreach ($this->bookings as $booking){
if($booking->travel_country_id && $booking->travel_country) {
foreach ($this->bookings as $booking) {
if ($booking->travel_country_id && $booking->travel_country) {
$out .= $booking->travel_country->destco;
}
}
$out .= $badge ? '</span>' : '';
return $out;
}
if($this->travel_country){
return $badge ? '<span class="badge badge-secondary">'.$this->travel_country->destco.'</span>' : $this->travel_country->destco;
if ($this->travel_country) {
return $badge ? '<span class="badge badge-secondary">' . $this->travel_country->destco . '</span>' : $this->travel_country->destco;
}
return "-";
}
public function countLeadMailsBy($dir, $subdir=false){
if($dir === 11){
public function countLeadMailsBy($dir, $subdir = false)
{
if ($dir === 11) {
return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count();
}
if($subdir){
if ($subdir) {
return $this->lead_mails->where('dir', $dir)->where('subdir', $subdir)->count();
}
return $this->lead_mails->where('dir', $dir)->count();
}
public function getPassolutionPDF($create = false, $resync = false){
public function getPassolutionPDF($create = false, $resync = false)
{
$nats = [];
if(count($this->passolutionPDFs)){
if (count($this->passolutionPDFs)) {
return $this->passolutionPDFs;
}
if(!$this->travel_country){
if (!$this->travel_country) {
return $this->passolutionPDFs;
}
@ -388,17 +400,17 @@ class Lead extends Model
//default no travel_nationality
$nats['de'] = 'de';
if($this->lead_participants->count()){
foreach ($this->lead_participants as $participant){
if($participant->travel_nationality){
if ($this->lead_participants->count()) {
foreach ($this->lead_participants as $participant) {
if ($participant->travel_nationality) {
$nats[$participant->travel_nationality->nat] = $participant->travel_nationality->nat;
}
}
}
if(empty($nats)){
if (empty($nats)) {
$nats['de'] = 'de';
}
foreach ($nats as $nat){
foreach ($nats as $nat) {
$data = [
'nat' => $nat,
'destco' => $destco,
@ -409,7 +421,8 @@ class Lead extends Model
return $this->passolutionPDFs;
}
public function resyncPassolutionPDF(){
public function resyncPassolutionPDF()
{
return $this->getPassolutionPDF(true, true);
}
}

View file

@ -1,56 +1,132 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Offer
* Angebot (Modul 6).
*
* Ein Offer ist der logische Angebots-Kopf (Angebotsnummer, Status,
* Referenzen). Die Inhalte (Texte, Positionen, PDF) liegen versionsweise
* in {@see OfferVersion}. Nach dem ersten Versand ist jede Änderung
* eine neue Version (Entscheidung 17.1 Entwicklungsplan).
*
* @property int $id
* @property int $lead_id
* @property float $total
* @property boolean $binary_data
* @property string $offer_number
* @property int $contact_id
* @property int|null $inquiry_id
* @property int|null $booking_id
* @property string $status
* @property int|null $current_version_id
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Lead $lead
* @package App\Models
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer query()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereBinaryData($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereLeadId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereTotal($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereUpdatedAt($value)
* @mixin \Eloquent
* @property Carbon|null $deleted_at
* @property-read Contact $contact
* @property-read Lead|null $inquiry
* @property-read Booking|null $booking
* @property-read OfferVersion|null $currentVersion
* @property-read Collection|OfferVersion[] $versions
* @property-read Collection|OfferAccessToken[] $accessTokens
* @property-read User $creator
*/
class Offer extends Model
{
protected $connection = 'mysql';
use HasFactory, SoftDeletes;
protected $table = 'offer';
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_EXPIRED = 'expired';
public const STATUS_WITHDRAWN = 'withdrawn';
protected $casts = [
'lead_id' => 'int',
'total' => 'float',
'binary_data' => 'boolean'
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_SENT,
self::STATUS_ACCEPTED,
self::STATUS_DECLINED,
self::STATUS_EXPIRED,
self::STATUS_WITHDRAWN,
];
protected $table = 'offers';
protected $fillable = [
'lead_id',
'total',
'binary_data'
'offer_number',
'contact_id',
'inquiry_id',
'booking_id',
'status',
'current_version_id',
'created_by',
];
public function lead()
protected $casts = [
'contact_id' => 'int',
'inquiry_id' => 'int',
'booking_id' => 'int',
'current_version_id' => 'int',
'created_by' => 'int',
];
public function contact(): BelongsTo
{
return $this->belongsTo(Lead::class);
return $this->belongsTo(Contact::class);
}
public function inquiry(): BelongsTo
{
// Nach Modul 3 Phase 2: `Lead`-Model bildet die `inquiries`-Tabelle ab
return $this->belongsTo(Lead::class, 'inquiry_id');
}
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function currentVersion(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'current_version_id');
}
public function versions(): HasMany
{
return $this->hasMany(OfferVersion::class)->orderBy('version_no');
}
public function accessTokens(): HasMany
{
return $this->hasMany(OfferAccessToken::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeStatus(Builder $q, string $status): Builder
{
return $q->where('status', $status);
}
public function scopeOpen(Builder $q): Builder
{
return $q->whereIn('status', [self::STATUS_DRAFT, self::STATUS_SENT]);
}
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SENT], true);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
*
* In der Datenbank wird ausschließlich der SHA-256-Hash des Klartext-
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
* per Mail-Link ausgeliefert.
*
* Pro Angebot + Version existiert genau ein aktiver Token; wird eine
* neue Version versendet, setzt der OfferService den Vorgänger auf
* `revoked_at`.
*
* @property int $id
* @property int $offer_id
* @property int $offer_version_id
* @property string $token_hash
* @property Carbon|null $expires_at
* @property Carbon|null $first_opened_at
* @property Carbon|null $revoked_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Offer $offer
* @property-read OfferVersion $version
*/
class OfferAccessToken extends Model
{
use HasFactory;
protected $table = 'offer_access_tokens';
protected $fillable = [
'offer_id',
'offer_version_id',
'token_hash',
'expires_at',
'first_opened_at',
'revoked_at',
];
protected $casts = [
'offer_id' => 'int',
'offer_version_id' => 'int',
'expires_at' => 'datetime',
'first_opened_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
}
public function version(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function scopeActive(Builder $q): Builder
{
return $q->whereNull('revoked_at')
->where(function (Builder $q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
});
}
/**
* Erzeugt ein neues Token für die angegebene Version und liefert
* den Klartext-Token zurück (nur einmalig abrufbar). In der
* Datenbank wird nur der Hash persistiert.
*/
public static function generate(
Offer $offer,
OfferVersion $version,
?Carbon $expiresAt = null
): array {
$plain = Str::random(48);
$hash = hash('sha256', $plain);
/** @var self $token */
$token = self::create([
'offer_id' => $offer->id,
'offer_version_id' => $version->id,
'token_hash' => $hash,
'expires_at' => $expiresAt,
]);
return ['plain' => $plain, 'token' => $token];
}
/**
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
*/
public static function findByPlainToken(string $plain): ?self
{
return self::where('token_hash', hash('sha256', $plain))->first();
}
public function isActive(): bool
{
if ($this->revoked_at !== null) {
return false;
}
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return false;
}
return true;
}
}

99
app/Models/OfferFile.php Normal file
View file

@ -0,0 +1,99 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Datei-Anhang einer Angebotsversion (Modul 6).
*
* Struktur ist bewusst an {@see BookingFile} angelehnt (identifier,
* filename, dir, original_name, ext, mine, size), damit der vorhandene
* `FileRepository::store()` 1:1 wiederverwendet werden kann. `mine`
* bleibt so geschrieben (statt `mime`) zur Konsistenz mit der
* booking_files-Konvention.
*
* @property int $id
* @property int $offer_version_id
* @property string|null $identifier
* @property string $filename
* @property string $dir
* @property string $original_name
* @property string $ext
* @property string $mine
* @property int $size
* @property bool $include_in_pdf
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read OfferVersion $version
*/
class OfferFile extends Model
{
use HasFactory;
protected $table = 'offer_files';
protected $fillable = [
'offer_version_id',
'identifier',
'filename',
'dir',
'original_name',
'ext',
'mine',
'size',
'include_in_pdf',
];
protected $casts = [
'offer_version_id' => 'int',
'size' => 'int',
'include_in_pdf' => 'bool',
];
public static array $iconExt = [
'default' => 'fa fa-file',
'pdf' => 'fa fa-file-pdf',
'jpg' => 'fa fa-file-image',
'jpeg' => 'fa fa-file-image',
'png' => 'fa fa-file-image',
'doc' => 'fa fa-file-word',
'docx' => 'fa fa-file-word',
];
public function version(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function getIconExt(): string
{
return self::$iconExt[$this->ext] ?? self::$iconExt['default'];
}
public function getURL(bool|string $do = false): string
{
return route('storage_file', [$this->id, 'offer', $do]);
}
public function getPath(): string
{
return \Storage::disk('offer')->path($this->dir . $this->filename);
}
public function formatBytes(int $precision = 2): string
{
$size = $this->size;
if ($size <= 0) {
return (string) $size;
}
$base = log($size) / log(1024);
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
}
}

86
app/Models/OfferItem.php Normal file
View file

@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Position einer Angebotsversion (Modul 6).
*
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
* `metadata` enthält einen Snapshot der Referenzdaten (Titel, Preis,
* Leistungen) so bleiben Positionen lesbar, auch wenn das Original
* später gelöscht / migriert / umbenannt wird.
*
* @property int $id
* @property int $offer_version_id
* @property int $position
* @property string $type
* @property string $title
* @property string|null $description
* @property int $quantity
* @property float $price_per_unit
* @property float $total_price
* @property int|null $travel_program_id
* @property int|null $fewo_lodging_id
* @property array|null $metadata
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read OfferVersion $version
*/
class OfferItem extends Model
{
use HasFactory;
public const TYPE_TRAVEL = 'travel';
public const TYPE_SERVICE = 'service';
public const TYPE_OPTION = 'option';
public const TYPE_DISCOUNT = 'discount';
public const TYPE_INSURANCE = 'insurance';
public const TYPE_CUSTOM = 'custom';
protected $table = 'offer_items';
protected $fillable = [
'offer_version_id',
'position',
'type',
'title',
'description',
'quantity',
'price_per_unit',
'total_price',
'travel_program_id',
'fewo_lodging_id',
'metadata',
];
protected $casts = [
'offer_version_id' => 'int',
'position' => 'int',
'quantity' => 'int',
'price_per_unit' => 'decimal:2',
'total_price' => 'decimal:2',
'travel_program_id' => 'int',
'fewo_lodging_id' => 'int',
'metadata' => 'array',
];
public function version(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
/**
* Aus Menge × Einzelpreis den Positions-Gesamtpreis berechnen
* (Rabatte negativ gehört in den Service-Layer zur Summierung).
*/
public function calculateTotal(): float
{
return round($this->quantity * (float) $this->price_per_unit, 2);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Wiederverwendbare Angebots-Vorlage (Modul 6).
*
* Liefert Default-Texte + Default-Positionen für neue Angebote.
* `default_items` ist ein JSON-Array von Positionen im Schema
* [{title, description, type, price_per_unit, quantity}, ].
*
* @property int $id
* @property int|null $branch_id
* @property string $name
* @property string|null $description
* @property string|null $default_headline
* @property string|null $default_intro
* @property string|null $default_itinerary
* @property string|null $default_closing
* @property array|null $default_items
* @property bool $is_active
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon|null $deleted_at
* @property-read Branch|null $branch
* @property-read User $creator
*/
class OfferTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'offer_templates';
protected $fillable = [
'branch_id',
'name',
'description',
'default_headline',
'default_intro',
'default_itinerary',
'default_closing',
'default_items',
'is_active',
'created_by',
];
protected $casts = [
'branch_id' => 'int',
'default_items' => 'array',
'is_active' => 'bool',
'created_by' => 'int',
];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeActive(Builder $q): Builder
{
return $q->where('is_active', true);
}
}

126
app/Models/OfferVersion.php Normal file
View file

@ -0,0 +1,126 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Version eines Angebots (Modul 6).
*
* Jede versendete Fassung wird hier festgehalten Texte, Positionen
* und PDF bleiben damit unveränderlich, sobald ein Kunde sie per
* Freigabe-Link einsehen kann. Neue Änderungen nach dem Versand
* erzeugen eine neue Version (version_no = max+1, status = draft).
*
* @property int $id
* @property int $offer_id
* @property int $version_no
* @property string $status
* @property Carbon|null $valid_until
* @property float $total_price
* @property string|null $headline
* @property string|null $intro_text
* @property string|null $itinerary_text
* @property string|null $closing_text
* @property int|null $template_id
* @property string|null $pdf_path
* @property bool $pdf_archived
* @property Carbon|null $sent_at
* @property Carbon|null $accepted_at
* @property string|null $accepted_via
* @property array|null $template_document_ids
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Offer $offer
* @property-read OfferTemplate|null $template
* @property-read Collection|OfferItem[] $items
* @property-read Collection|OfferFile[] $files
* @property-read User $creator
*/
class OfferVersion extends Model
{
use HasFactory;
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUPERSEDED = 'superseded';
public const ACCEPTED_VIA_LINK = 'customer_link';
public const ACCEPTED_VIA_ADMIN = 'admin';
public const ACCEPTED_VIA_MAIL = 'email';
protected $table = 'offer_versions';
protected $fillable = [
'offer_id',
'version_no',
'status',
'valid_until',
'total_price',
'headline',
'intro_text',
'itinerary_text',
'closing_text',
'template_id',
'pdf_path',
'pdf_archived',
'sent_at',
'accepted_at',
'accepted_via',
'template_document_ids',
'created_by',
];
protected $casts = [
'offer_id' => 'int',
'version_no' => 'int',
'valid_until' => 'date',
'total_price' => 'decimal:2',
'template_id' => 'int',
'pdf_archived' => 'bool',
'sent_at' => 'datetime',
'accepted_at' => 'datetime',
'template_document_ids' => 'array',
'created_by' => 'int',
];
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
}
public function template(): BelongsTo
{
return $this->belongsTo(OfferTemplate::class, 'template_id');
}
public function items(): HasMany
{
return $this->hasMany(OfferItem::class)->orderBy('position');
}
public function files(): HasMany
{
return $this->hasMany(OfferFile::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
}

View file

@ -63,13 +63,13 @@ class BookingPDFRepository extends BaseRepository
{
$document = new stdClass();
$document->name = 'registration';
$document->number = $this->model->lead_id;
$document->number = $this->model->inquiry_id;
$document->title = 'BUCHUNGSAUFTRAG';
$document->voucher = null;
$document->date = now();
$document->total = $this->model->getPriceRaw();
$dir = $this->getDirPath('pdf', 'booking', $document->date->format('Y'));
$filename = "Buchnungsauftrag-" . $this->model->lead_id . ".pdf";
$filename = "Buchnungsauftrag-" . $this->model->inquiry_id . ".pdf";
$pdf_file = new CreatePDF('pdf.booking_registration');
$data = [
'booking' => $this->model,
@ -85,7 +85,7 @@ class BookingPDFRepository extends BaseRepository
{
$document = new stdClass();
$document->name = 'confirmation';
$document->number = $this->model->lead_id;
$document->number = $this->model->inquiry_id;
$document->title = 'REISEBESTÄTIGUNG';
$document->voucher = null;
$document->date = now();
@ -104,7 +104,7 @@ class BookingPDFRepository extends BaseRepository
$document->final_payment_date = date('Y-m-d');
}
$dir = $this->getDirPath('pdf', 'booking', $document->date->format('Y'));
$filename = "Reisebestätigung-" . $this->model->lead_id . ".pdf";
$filename = "Reisebestätigung-" . $this->model->inquiry_id . ".pdf";
$pdf_file = new CreatePDF('pdf.booking_confirmation');
$data = [
@ -160,14 +160,14 @@ class BookingPDFRepository extends BaseRepository
{
$document = new stdClass();
$document->name = 'voucher';
$document->number = $this->model->lead_id;
$document->number = $this->model->inquiry_id;
$document->name = 'voucher';
$document->title = $agency ? 'VOUCHER Agentur' : 'VOUCHER';
$document->voucher = $agency ? 'agency' : 'client';
$document->date = now();
$dir = $this->getDirPath('pdf', 'voucher', $document->date->format('Y'));
$filename = ($agency ? 'VoucherAgentur' : 'Voucher') . "-" . $this->model->lead_id . ".pdf";
$filename = ($agency ? 'VoucherAgentur' : 'Voucher') . "-" . $this->model->inquiry_id . ".pdf";
$pdf_file = new CreatePDF('pdf.booking_voucher');
$data = [
@ -224,7 +224,7 @@ class BookingPDFRepository extends BaseRepository
//init document
$document = new stdClass();
$document->name = $identifier;
$document->number = $this->model->lead_id;
$document->number = $this->model->inquiry_id;
$document->title = 'STORNOBESTÄTIGUNG';
$document->voucher = null;
$document->date = Carbon::parse($data['storno_print']);
@ -253,7 +253,7 @@ class BookingPDFRepository extends BaseRepository
$dir = $this->getDirPath('pdf', 'storno', $document->date->format('Y'));
$filename = "Reisestornierung -" . $this->model->lead_id . ".pdf";
$filename = "Reisestornierung -" . $this->model->inquiry_id . ".pdf";
$pdf_file = new CreatePDF('pdf.booking_storno');
$data = [
@ -288,7 +288,9 @@ class BookingPDFRepository extends BaseRepository
$fill = [
'booking_id' => $this->model->id,
'customer_id' => $this->model->customer_id,
'lead_id' => $this->model->lead_id,
// booking_documents.lead_id ist ein Shadow-Feld von booking.inquiry_id;
// die Spalte selbst wird von Phase 2 nicht umbenannt.
'lead_id' => $this->model->inquiry_id,
'identifier' => $identifier,
'filename' => $filename,
'dir' => $dir,

View file

@ -157,11 +157,12 @@ class BookingRepository extends BaseRepository
{
$this->model = Booking::findOrFail($id);
$this->model->setPriceTotalForCurrentState();
$fill = [
'deposit_total' => $data['deposit_total'] ? Util::_clean_float($data['deposit_total']) : 0,
'final_payment' => $data['final_payment'] ? Util::_clean_float($data['final_payment']) : 0,
'final_payment_date' => $data['final_payment_date'] ? _reformat_date($data['final_payment_date']) : null,
'price_total' => ($this->model->getPriceRaw() + $this->model->getServiceTotal(true)),
'price_total' => $this->model->getPriceTotalRaw(),
];
$this->model->fill($fill);
$this->model->save();
@ -210,7 +211,7 @@ class BookingRepository extends BaseRepository
}
}
}
$this->model->price_total = ($this->model->getPriceRaw() + $this->model->getServiceTotal(true));
$this->model->setPriceTotalForCurrentState();
$this->model->save();
return $this->model;

View file

@ -135,7 +135,8 @@ class CustomerMailRepository extends BaseRepository {
$customer_mail->fill([
'booking_id' => $booking->id,
'customer_id' => $booking->customer_id,
'lead_id' => $booking->lead_id,
// customer_mails.lead_id-Spalte bleibt unverändert; Wert kommt aus booking.inquiry_id
'lead_id' => $booking->inquiry_id,
'is_answer' => $is_answer,
'reply_id' => $reply_id,
'email' => $mail_from,
@ -153,7 +154,8 @@ class CustomerMailRepository extends BaseRepository {
$customer_mail = CustomerMail::create([
'booking_id' => $booking->id,
'customer_id' => $booking->customer_id,
'lead_id' => $booking->lead_id,
// customer_mails.lead_id-Spalte bleibt unverändert; Wert kommt aus booking.inquiry_id
'lead_id' => $booking->inquiry_id,
'is_answer' => $is_answer,
'reply_id' => $reply_id,
'email' => $mail_from,
@ -300,7 +302,7 @@ class CustomerMailRepository extends BaseRepository {
$value->id = $customer_mail->booking_id;
$value->booking = $booking;
$value->show = 'single';
$value->lead_title_id = " - (".$value->booking->lead_id.")";
$value->lead_title_id = " - (".$value->booking->inquiry_id.")";
$tmp = [];
@ -342,7 +344,7 @@ class CustomerMailRepository extends BaseRepository {
$value->booking = $booking;
$value->show = 'single';
$value->draft = true;
$value->lead_title_id = " - (".$value->booking->lead_id.")";
$value->lead_title_id = " - (".$value->booking->inquiry_id.")";
}else{
//multi
@ -379,8 +381,8 @@ class CustomerMailRepository extends BaseRepository {
$value->draft = false;
$value->booking = $booking;
$value->message = "";
$value->subject = " - (".$value->booking->lead_id.")";
$value->lead_title_id = " - (".$value->booking->lead_id.")";
$value->subject = " - (".$value->booking->inquiry_id.")";
$value->lead_title_id = " - (".$value->booking->inquiry_id.")";
$value->s_placeholder = "Betreff des Kunden";
$value->m_placeholder = "Nachricht des Kunden";
if(isset($data['customer_mail_id']) && $customer_mail = CustomerMail::find($data['customer_mail_id'])){

View file

@ -134,7 +134,7 @@ class LeadRepository extends BaseRepository {
$data = [
'booking_date' => date('Y-m-d'), //now
'customer_id' => $this->model->customer->id,
'lead_id' => $this->model->id,
'inquiry_id' => $this->model->id,
'new_drafts' => 1,
'sf_guard_user_id' => $this->model->sf_guard_user_id,
'branch_id' => 4,

View file

@ -15,7 +15,7 @@ class Booking
->pluck('slug', 'id');
}
public static function setOutputDirs(string $dir, string $subdir): void
public static function setOutputDirs(string $dir, ?string $subdir): void
{
MailDirService::setOutputDir($dir, $subdir);
}
@ -39,12 +39,12 @@ class Booking
return MailDirService::getCustomerMailDir($id);
}
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, int $mailDirId): string
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, ?int $mailDirId): string
{
return MailDirService::getCustomerMailName($mailDir, $mailDirId);
}
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, int $mailDirId): array|string
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, ?int $mailDirId): array|string
{
return MailDirService::getCustomerMailEmails($mailDir, $mailDirId);
}

View file

@ -53,7 +53,7 @@ class BookingImport
$data = [
'booking_date' => $travel_booking->created->format('Y-m-d'),
'customer_id' => $customer->id,
'lead_id' => $lead->id,
'inquiry_id' => $lead->id,
'new_drafts' => $travel_booking->drafts === null ? 0 : 1,
'sf_guard_user_id' => 15,
'branch_id' => 4,

View file

@ -15,7 +15,7 @@ class Lead
->pluck('slug', 'id');
}
public static function setOutputDirs(string $dir, string $subdir): void
public static function setOutputDirs(string $dir, ?string $subdir): void
{
MailDirService::setOutputDir($dir, $subdir);
}
@ -39,12 +39,12 @@ class Lead
return MailDirService::getCustomerMailDir($id);
}
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, int $mailDirId): string
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, ?int $mailDirId): string
{
return MailDirService::getCustomerMailName($mailDir, $mailDirId);
}
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, int $mailDirId): array|string
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, ?int $mailDirId): array|string
{
return MailDirService::getCustomerMailEmails($mailDir, $mailDirId);
}

View file

@ -11,7 +11,7 @@ class MailDirService
{
private static array $outputDirs = [];
public static function setOutputDir(string $dir, string $subdir): void
public static function setOutputDir(string $dir, ?string $subdir): void
{
self::$outputDirs[$dir][] = $subdir;
}
@ -29,7 +29,7 @@ class MailDirService
return CMSContent::where('identifier', '=', 'customer-mail-dirs')->where('pos', '=', $id)->first();
}
public static function getCustomerMailName(CMSContent $mailDir, int $mailDirId): string
public static function getCustomerMailName(CMSContent $mailDir, ?int $mailDirId): string
{
$model = self::resolveModel($mailDir, $mailDirId);
@ -47,7 +47,7 @@ class MailDirService
/**
* @return array<string>|string
*/
public static function getCustomerMailEmails(CMSContent $mailDir, int $mailDirId): array|string
public static function getCustomerMailEmails(CMSContent $mailDir, ?int $mailDirId): array|string
{
$model = self::resolveModel($mailDir, $mailDirId);
@ -79,8 +79,11 @@ class MailDirService
return $result;
}
private static function resolveModel(CMSContent $mailDir, int $mailDirId): mixed
private static function resolveModel(CMSContent $mailDir, ?int $mailDirId): mixed
{
if ($mailDirId === null) {
return null;
}
return match ($mailDir->getArrayContent('model')) {
'TravelCountry' => \App\Models\Sym\TravelCountry::find($mailDirId),
'Airline' => Airline::find($mailDirId),

File diff suppressed because it is too large Load diff

View file

@ -72,6 +72,12 @@ return [
'url' => env('APP_URL').'/storage/booking',
'visibility' => 'public',
],
'offer' => [
'driver' => 'local',
'root' => storage_path('app/offer'),
'url' => env('APP_URL').'/storage/offer',
'visibility' => 'public',
],
'general' => [
'driver' => 'local',
'root' => storage_path('app/general'),

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Phase 2 Schritt 1: customer contacts
*
* Benennt die Tabelle um. Alle Foreign Keys, die auf `customer` zeigen,
* werden von MySQL automatisch mitgezogen (ON DELETE / ON UPDATE bleiben).
*
* Rollback: benennt `contacts` zurück in `customer`.
*
* HINWEIS: Vor dem Deployment sicherstellen, dass die App-Models
* bereits $table = 'contacts' verwenden, ODER die Migration vor dem
* Code-Deployment ausführen und im Notfall zurückrollen.
*/
return new class extends Migration
{
public function up(): void
{
// MySQL: RENAME TABLE ist ein atomarer DDL-Befehl
DB::statement('RENAME TABLE `customer` TO `contacts`');
}
public function down(): void
{
DB::statement('RENAME TABLE `contacts` TO `customer`');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Phase 2 Schritt 2: lead inquiries
*
* Benennt die Tabelle um. Abhängige Tabellen:
* - lead_mails (lead_id FK wird mitgezogen)
* - lead_files (lead_id FK wird mitgezogen)
* - lead_notices (lead_id FK wird mitgezogen)
* - lead_participant (lead_id FK wird mitgezogen)
* - booking (lead_id FK wird mitgezogen)
* - customer_mails (lead_id FK wird mitgezogen)
*
* Rollback: benennt `inquiries` zurück in `lead`.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement('RENAME TABLE `lead` TO `inquiries`');
}
public function down(): void
{
DB::statement('RENAME TABLE `inquiries` TO `lead`');
}
};

View file

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Phase 2 Schritt 3: booking.lead_id booking.inquiry_id
*
* Benennt die Spalte und den Foreign Key um.
* inquiry_id bleibt nullable (Direktbuchungen ohne Anfrage sind erlaubt).
*
* Rollback: benennt inquiry_id zurück in lead_id.
*/
return new class extends Migration
{
public function up(): void
{
// Foreign Key muss zuerst gedroppt werden, bevor die Spalte umbenannt wird
Schema::table('booking', function (Blueprint $table) {
// Drop existing FK (Name aus DB-Schema)
$table->dropForeign('booking_lead_id_lead_id');
});
DB::statement('ALTER TABLE `booking` RENAME COLUMN `lead_id` TO `inquiry_id`');
Schema::table('booking', function (Blueprint $table) {
// Neuen FK auf umbenannte Tabelle setzen
$table->foreign('inquiry_id', 'booking_inquiry_id_inquiries_id')
->references('id')->on('inquiries')
->onDelete('no action')
->onUpdate('no action');
});
}
public function down(): void
{
Schema::table('booking', function (Blueprint $table) {
$table->dropForeign('booking_inquiry_id_inquiries_id');
});
DB::statement('ALTER TABLE `booking` RENAME COLUMN `inquiry_id` TO `lead_id`');
Schema::table('booking', function (Blueprint $table) {
$table->foreign('lead_id', 'booking_lead_id_lead_id')
->references('id')->on('lead')
->onDelete('no action')
->onUpdate('no action');
});
}
};

View file

@ -0,0 +1,123 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Phase 3 Schritt 1: Neue participants-Tabelle erstellen und Daten migrieren
*
* Konsolidiert lead_participant + participant in eine einheitliche Tabelle.
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 3 Schritt 2
* nach Abschluss der Tests gedroppt).
*
* Neue Felder:
* - inquiry_id : FK auf inquiries (war: lead_participant.lead_id)
* - booking_id : FK auf booking (war: participant.booking_id)
* - participant_pass : nur bei Buchungs-Teilnehmern relevant
* - participant_storno: nur bei Buchungs-Teilnehmern relevant
* - is_lead_contact : markiert den Hauptkontakt aus lead.participant_name
*
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('participants_unified', function (Blueprint $table) {
$table->id();
// Kontext-FKs — genau einer ist gesetzt, der andere NULL
$table->unsignedBigInteger('inquiry_id')->nullable();
$table->unsignedBigInteger('booking_id')->nullable();
// Teilnehmerdaten
$table->string('participant_name')->nullable();
$table->string('participant_firstname')->nullable();
$table->date('participant_birthdate')->nullable();
$table->unsignedBigInteger('participant_salutation_id')->nullable();
$table->boolean('participant_child')->default(false);
$table->integer('nationality_id')->nullable();
// Nur bei Buchungs-Teilnehmern
$table->boolean('participant_pass')->default(false);
$table->boolean('participant_storno')->default(false);
// Markiert den Hauptreisenden aus lead.participant_name
$table->boolean('is_lead_contact')->default(false);
$table->timestamps();
$table->index('inquiry_id');
$table->index('booking_id');
$table->foreign('inquiry_id', 'pu_inquiry_id_fk')
->references('id')->on('inquiries')
->onDelete('cascade');
$table->foreign('booking_id', 'pu_booking_id_fk')
->references('id')->on('booking')
->onDelete('cascade');
$table->foreign('participant_salutation_id', 'pu_salutation_id_fk')
->references('id')->on('salutation')
->onDelete('set null');
});
// ── Daten migrieren ─────────────────────────────────────────────────
// 1. Aus lead_participant → inquiry_id gesetzt
DB::statement("
INSERT INTO participants_unified
(inquiry_id, booking_id, participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, participant_child,
nationality_id, is_lead_contact, created_at, updated_at)
SELECT
lead_id, NULL,
participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, participant_child,
nationality_id, 0,
NOW(), NOW()
FROM lead_participant
");
// 2. Aus lead.participant_name → Hauptreisende-Datensätze (is_lead_contact = 1)
DB::statement("
INSERT INTO participants_unified
(inquiry_id, booking_id, participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, participant_child,
nationality_id, is_lead_contact, created_at, updated_at)
SELECT
id, NULL,
participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, 0,
NULL, 1,
NOW(), NOW()
FROM inquiries
WHERE participant_name IS NOT NULL
AND participant_name != ''
");
// 3. Aus participant → booking_id gesetzt
DB::statement("
INSERT INTO participants_unified
(inquiry_id, booking_id, participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, participant_child,
nationality_id, participant_pass, participant_storno,
is_lead_contact, created_at, updated_at)
SELECT
NULL, booking_id,
participant_name, participant_firstname,
participant_birthdate, participant_salutation_id, participant_child,
nationality_id, participant_pass, participant_storno,
0, NOW(), NOW()
FROM participant
");
}
public function down(): void
{
Schema::dropIfExists('participants_unified');
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
/**
* Phase 3 Schritt 2: Alte Participant-Tabellen droppen
*
* Voraussetzungen (vor Ausführung prüfen!):
* 1. Phase 3 Schritt 1 (participants_unified) läuft stabil in Produktion
* 2. Alle Queries / Repositories auf participants_unified umgestellt
* 3. Datenmigration durch Vergleich der Zeilenzahlen geprüft:
* SELECT COUNT(*) FROM lead_participant;
* SELECT COUNT(*) FROM participant;
* SELECT COUNT(*) FROM participants_unified WHERE inquiry_id IS NOT NULL;
* SELECT COUNT(*) FROM participants_unified WHERE booking_id IS NOT NULL;
*
* Rollback: NICHT möglich ohne Datenbank-Backup erst ausführen wenn sicher!
*/
return new class extends Migration
{
public function up(): void
{
// FKs in lead_participant zeigen auf lead (jetzt inquiries) — werden mitgedroppt
Schema::dropIfExists('lead_participant');
// FKs in participant zeigen auf booking — werden mitgedroppt
Schema::dropIfExists('participant');
}
public function down(): void
{
// Bewusst leer: das Droppen von Produktionsdaten ist irreversibel.
// Für einen vollständigen Rollback bitte Datenbank-Backup einspielen.
throw new \RuntimeException(
'Phase 3 Schritt 2 kann nicht automatisch zurückgerollt werden. ' .
'Bitte Datenbank-Backup einspielen.'
);
}
};

View file

@ -0,0 +1,146 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Phase 4 Schritt 1a: communications-Tabelle erstellen und Daten migrieren
*
* Konsolidiert lead_mails + customer_mails in eine einheitliche Tabelle.
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
*
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
* (lead inquiries, customer contacts).
*
* Besonderheiten:
* - reply_id ist selbst-referenziell; nach beiden INSERTs wird ein Remapping
* der alten IDs auf die neuen IDs durchgeführt (via legacy_source + legacy_id).
* - travel_country_id existiert nur in customer_mails nullable, NULL für lead_mails.
* - customer_mails hat sowohl lead_id als auch booking_id beide werden übernommen.
*
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('communications', function (Blueprint $table) {
$table->id();
// Kontext-FKs — bei customer_mails können beide gesetzt sein
$table->unsignedBigInteger('inquiry_id')->nullable();
$table->unsignedBigInteger('booking_id')->nullable();
$table->unsignedBigInteger('contact_id')->nullable();
// Reply-Chain — kein FK-Constraint (cross-table Remapping)
$table->unsignedBigInteger('reply_id')->nullable();
// Herkunft für Remapping und spätere Analyse
$table->enum('legacy_source', ['lead_mail', 'customer_mail']);
$table->unsignedBigInteger('legacy_id');
// Mail-Felder (identisch in beiden Quell-Tabellen)
$table->boolean('is_answer')->default(false);
$table->string('email')->nullable();
$table->text('recipient')->nullable();
$table->text('cc')->nullable();
$table->text('bcc')->nullable();
$table->string('subject')->nullable();
$table->text('message')->nullable();
$table->boolean('dir')->default(false);
$table->unsignedBigInteger('subdir')->nullable();
// Nur in customer_mails vorhanden
$table->unsignedBigInteger('travel_country_id')->nullable();
$table->boolean('draft')->default(false);
$table->boolean('important')->default(false);
$table->boolean('send')->default(false);
$table->boolean('fail')->default(false);
$table->text('error')->nullable();
$table->text('forward')->nullable();
$table->dateTime('sent_at')->nullable();
$table->dateTime('scheduled_at')->nullable();
$table->dateTime('delivered_at')->nullable();
$table->timestamps();
$table->index('inquiry_id');
$table->index('booking_id');
$table->index('contact_id');
$table->index('reply_id');
$table->index(['legacy_source', 'legacy_id'], 'comm_legacy_idx');
$table->foreign('inquiry_id', 'comm_inquiry_id_fk')
->references('id')->on('inquiries')
->onDelete('cascade');
$table->foreign('booking_id', 'comm_booking_id_fk')
->references('id')->on('booking')
->onDelete('cascade');
$table->foreign('contact_id', 'comm_contact_id_fk')
->references('id')->on('contacts')
->onDelete('set null');
});
// ── Daten migrieren ─────────────────────────────────────────────────
// 1. Aus lead_mails → inquiry_id gesetzt
DB::statement("
INSERT INTO communications
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
legacy_source, legacy_id,
email, recipient, cc, bcc, subject, message,
dir, subdir, travel_country_id,
draft, important, send, fail, error, forward,
sent_at, scheduled_at, delivered_at, created_at, updated_at)
SELECT
lead_id, NULL, customer_id, is_answer, reply_id,
'lead_mail', id,
email, recipient, cc, bcc, subject, message,
dir, subdir, NULL,
draft, important, send, fail, error, forward,
sent_at, scheduled_at, delivered_at, created_at, updated_at
FROM lead_mails
");
// 2. Aus customer_mails → booking_id gesetzt (kann zusätzlich lead_id haben)
DB::statement("
INSERT INTO communications
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
legacy_source, legacy_id,
email, recipient, cc, bcc, subject, message,
dir, subdir, travel_country_id,
draft, important, send, fail, error, forward,
sent_at, scheduled_at, delivered_at, created_at, updated_at)
SELECT
lead_id, booking_id, customer_id, is_answer, reply_id,
'customer_mail', id,
email, recipient, cc, bcc, subject, message,
dir, subdir, travel_country_id,
draft, important, send, fail, error, forward,
sent_at, scheduled_at, delivered_at, created_at, updated_at
FROM customer_mails
");
// 3. Reply-IDs remappen: alte source-table-IDs auf neue communications-IDs umstellen
// Funktioniert weil reply_id immer auf eine Mail derselben Quell-Tabelle zeigt.
DB::statement("
UPDATE communications c1
INNER JOIN communications c2
ON c1.legacy_source = c2.legacy_source
AND c2.legacy_id = c1.reply_id
SET c1.reply_id = c2.id
WHERE c1.reply_id IS NOT NULL
");
}
public function down(): void
{
Schema::dropIfExists('communications');
}
};

View file

@ -0,0 +1,94 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Phase 4 Schritt 1b: notices-Tabelle erstellen und Daten migrieren
*
* Konsolidiert lead_notices + booking_notices in eine einheitliche Tabelle.
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
*
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
* (lead inquiries, customer contacts).
*
* Struktur: identisch in beiden Quell-Tabellen nur inquiry_id vs. booking_id.
*
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('notices', function (Blueprint $table) {
$table->id();
// Kontext-FKs — genau einer ist gesetzt
$table->unsignedBigInteger('inquiry_id')->nullable();
$table->unsignedBigInteger('booking_id')->nullable();
// Benutzer-Referenzen (integer wie in den Quell-Tabellen)
$table->unsignedInteger('from_user_id');
$table->unsignedInteger('to_user_id')->nullable();
$table->text('message')->nullable();
$table->boolean('show')->default(false);
$table->boolean('important')->default(false);
$table->dateTime('edit_at')->nullable();
$table->timestamps();
$table->index('inquiry_id');
$table->index('booking_id');
$table->index('from_user_id');
$table->index('to_user_id');
$table->foreign('inquiry_id', 'notices_inquiry_id_fk')
->references('id')->on('inquiries')
->onDelete('cascade');
$table->foreign('booking_id', 'notices_booking_id_fk')
->references('id')->on('booking')
->onDelete('cascade');
$table->foreign('from_user_id', 'notices_from_user_id_fk')
->references('id')->on('users')
->onDelete('cascade');
$table->foreign('to_user_id', 'notices_to_user_id_fk')
->references('id')->on('users')
->onDelete('cascade');
});
// ── Daten migrieren ─────────────────────────────────────────────────
// 1. Aus lead_notices → inquiry_id gesetzt
DB::statement("
INSERT INTO notices
(inquiry_id, booking_id, from_user_id, to_user_id,
message, show, important, edit_at, created_at, updated_at)
SELECT
lead_id, NULL, from_user_id, to_user_id,
message, show, important, edit_at, created_at, updated_at
FROM lead_notices
");
// 2. Aus booking_notices → booking_id gesetzt
DB::statement("
INSERT INTO notices
(inquiry_id, booking_id, from_user_id, to_user_id,
message, show, important, edit_at, created_at, updated_at)
SELECT
NULL, booking_id, from_user_id, to_user_id,
message, show, important, edit_at, created_at, updated_at
FROM booking_notices
");
}
public function down(): void
{
Schema::dropIfExists('notices');
}
};

View file

@ -0,0 +1,118 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Phase 4 Schritt 1c: attachments-Tabelle erstellen und Daten migrieren
*
* Konsolidiert lead_files + booking_files in eine einheitliche Tabelle.
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
*
* Voraussetzung: Phase 2 + Phase 4a (communications) müssen bereits ausgeführt sein,
* da communication_id auf die neue communications-Tabelle verweist.
*
* Besonderheiten:
* - lead_files hat lead_mail_id (FK auf lead_mails) wird auf communication_id gemappt
* - booking_files hat keinen Mail-Bezug communication_id bleibt NULL
* - Spalte `mine` in den Quell-Tabellen ist ein Tippfehler für mime hier: mime_type
*
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
// Kontext-FKs
$table->unsignedBigInteger('inquiry_id')->nullable();
$table->unsignedBigInteger('booking_id')->nullable();
// Verknüpfung zur dazugehörigen Mail (nur bei Anfrage-Anhängen)
$table->unsignedBigInteger('communication_id')->nullable();
// Datei-Metadaten
$table->string('identifier')->nullable();
$table->string('filename');
$table->string('dir')->nullable();
$table->string('original_name')->nullable();
$table->string('ext')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedInteger('size')->nullable();
// Herkunft für spätere Analyse / Rollback
$table->enum('legacy_source', ['lead_file', 'booking_file']);
$table->unsignedBigInteger('legacy_id');
$table->timestamps();
$table->index('inquiry_id');
$table->index('booking_id');
$table->index('communication_id');
$table->index('identifier', 'attachments_identifier_idx');
$table->index(['legacy_source', 'legacy_id'], 'attachments_legacy_idx');
$table->foreign('inquiry_id', 'att_inquiry_id_fk')
->references('id')->on('inquiries')
->onDelete('cascade');
$table->foreign('booking_id', 'att_booking_id_fk')
->references('id')->on('booking')
->onDelete('cascade');
$table->foreign('communication_id', 'att_communication_id_fk')
->references('id')->on('communications')
->onDelete('set null');
});
// ── Daten migrieren ─────────────────────────────────────────────────
// 1. Aus lead_files → inquiry_id gesetzt; communication_id wird unten gesetzt
DB::statement("
INSERT INTO attachments
(inquiry_id, booking_id, communication_id,
identifier, filename, dir, original_name, ext, mime_type, size,
legacy_source, legacy_id, created_at, updated_at)
SELECT
lead_id, NULL, NULL,
identifier, filename, dir, original_name, ext, mine, size,
'lead_file', id, created_at, updated_at
FROM lead_files
");
// 2. Aus booking_files → booking_id gesetzt
DB::statement("
INSERT INTO attachments
(inquiry_id, booking_id, communication_id,
identifier, filename, dir, original_name, ext, mime_type, size,
legacy_source, legacy_id, created_at, updated_at)
SELECT
NULL, booking_id, NULL,
identifier, filename, dir, original_name, ext, mine, size,
'booking_file', id, created_at, updated_at
FROM booking_files
");
// 3. communication_id für lead_files setzen (sofern lead_mail_id gesetzt war)
// Nutzt legacy_source + legacy_id der communications-Tabelle.
DB::statement("
UPDATE attachments a
INNER JOIN lead_files lf ON lf.id = a.legacy_id
INNER JOIN communications c
ON c.legacy_source = 'lead_mail'
AND c.legacy_id = lf.lead_mail_id
SET a.communication_id = c.id
WHERE a.legacy_source = 'lead_file'
AND lf.lead_mail_id IS NOT NULL
");
}
public function down(): void
{
Schema::dropIfExists('attachments');
}
};

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
/**
* Phase 4 Schritt 2a: Alte Mail-Tabellen droppen
*
* Voraussetzungen (vor Ausführung prüfen!):
* 1. Phase 4 Schritt 1a (communications) läuft stabil in Produktion
* 2. Alle Queries / Repositories auf communications umgestellt
* 3. Datenmigration geprüft:
* SELECT COUNT(*) FROM lead_mails;
* SELECT COUNT(*) FROM customer_mails;
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'lead_mail';
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'customer_mail';
* 4. Reply-Chain korrekt: spot-check einiger Datensätze mit reply_id
*
* Rollback: NICHT möglich ohne Datenbank-Backup.
*/
return new class extends Migration
{
public function up(): void
{
// lead_files referenziert lead_mails → erst lead_files droppen oder FK bereits weg
// (lead_files wird in 400005 gedroppt — daher hier prüfen ob noch FK vorhanden)
// Sicherheitshalber: lead_files.lead_mail_id FK zuerst entfernen falls noch vorhanden
if (Schema::hasTable('lead_files') && Schema::hasColumn('lead_files', 'lead_mail_id')) {
Schema::table('lead_files', function ($table) {
$table->dropForeign('lead_files_lead_mail_id_foreign');
});
}
Schema::dropIfExists('lead_mails');
Schema::dropIfExists('customer_mails');
}
public function down(): void
{
throw new \RuntimeException(
'Phase 4 Schritt 2a kann nicht automatisch zurückgerollt werden. ' .
'Bitte Datenbank-Backup einspielen.'
);
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
/**
* Phase 4 Schritt 2b: Alte Notiz-Tabellen droppen
*
* Voraussetzungen (vor Ausführung prüfen!):
* 1. Phase 4 Schritt 1b (notices) läuft stabil in Produktion
* 2. Alle Queries / Repositories auf notices umgestellt
* 3. Datenmigration geprüft:
* SELECT COUNT(*) FROM lead_notices;
* SELECT COUNT(*) FROM booking_notices;
* SELECT COUNT(*) FROM notices WHERE inquiry_id IS NOT NULL;
* SELECT COUNT(*) FROM notices WHERE booking_id IS NOT NULL;
*
* Rollback: NICHT möglich ohne Datenbank-Backup.
*/
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('lead_notices');
Schema::dropIfExists('booking_notices');
}
public function down(): void
{
throw new \RuntimeException(
'Phase 4 Schritt 2b kann nicht automatisch zurückgerollt werden. ' .
'Bitte Datenbank-Backup einspielen.'
);
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
/**
* Phase 4 Schritt 2c: Alte Datei-Tabellen droppen
*
* Voraussetzungen (vor Ausführung prüfen!):
* 1. Phase 4 Schritt 1c (attachments) läuft stabil in Produktion
* 2. Alle Queries / Repositories auf attachments umgestellt
* 3. Datenmigration geprüft:
* SELECT COUNT(*) FROM lead_files;
* SELECT COUNT(*) FROM booking_files;
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file';
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'booking_file';
* 4. communication_id Verknüpfung geprüft:
* SELECT COUNT(*) FROM lead_files WHERE lead_mail_id IS NOT NULL;
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file' AND communication_id IS NOT NULL;
* -- Beide Zahlen sollten übereinstimmen
*
* Rollback: NICHT möglich ohne Datenbank-Backup.
*/
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('lead_files');
Schema::dropIfExists('booking_files');
}
public function down(): void
{
throw new \RuntimeException(
'Phase 4 Schritt 2c kann nicht automatisch zurückgerollt werden. ' .
'Bitte Datenbank-Backup einspielen.'
);
}
};

View file

@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 1 von 7.
*
* Erstellt die Haupttabelle `offers`. Jeder Datensatz hier ist ein
* logisches Angebot (eine Angebotsnummer). Die tatsächlichen Inhalte
* (Texte, Positionen, Preise, PDF, Dokumente) liegen versionsweise in
* `offer_versions`. Ab dem ersten Versand erzeugt jede Änderung eine
* neue Version (Entscheidung 17.1 des Entwicklungsplans).
*
* VORBEDINGUNG:
* - Modul 3 Phase 2 (Tabellen `contacts` + `inquiries`) muss
* eingespielt sein. Siehe dev/customer-bookings/umsetzung.md.
*
* Tabelle `offers.current_version_id` wird als FK erst in der Migration
* 2026_04_17_100007 gesetzt (zyklische Abhängigkeit zu `offer_versions`).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('offers', function (Blueprint $t) {
$t->id();
$t->string('offer_number', 32)->unique();
$t->foreignId('contact_id')
->constrained('contacts')
->restrictOnDelete();
$t->foreignId('inquiry_id')
->nullable()
->constrained('inquiries')
->nullOnDelete();
$t->foreignId('booking_id')
->nullable()
->constrained('booking')
->nullOnDelete();
$t->enum('status', [
'draft',
'sent',
'accepted',
'declined',
'expired',
'withdrawn',
])->default('draft');
// FK wird in 2026_04_17_100007 nachträglich gesetzt
$t->unsignedBigInteger('current_version_id')->nullable();
$t->foreignId('created_by')->constrained('users');
$t->timestamps();
$t->softDeletes();
$t->index(['status', 'contact_id']);
$t->index('inquiry_id');
});
}
public function down(): void
{
Schema::dropIfExists('offers');
}
};

View file

@ -0,0 +1,81 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 2 von 7.
*
* Versionstabelle der Angebote. Jede Änderung nach dem ersten Versand
* erzeugt hier einen neuen Datensatz (version_no = max+1). Eine Version
* kapselt den kompletten Inhalt (Texte, Summe, Gültigkeit, PDF-Pfad,
* Status). Positionen (`offer_items`) und Anhänge (`offer_files`) hängen
* an einer Version nicht am übergeordneten `offers`-Datensatz.
*
* VORBEDINGUNG: 2026_04_17_100001_create_offers_table.php
*/
return new class extends Migration
{
public function up(): void
{
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();
// offer_templates wird in Migration 4 erzeugt — FK dort,
// hier zunächst nullable + FK wird über Migration 4 nachgeholt
$t->unsignedBigInteger('template_id')->nullable();
$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();
// Referenz auf zentral hinterlegte Dokument-Vorlagen,
// die mit dieser Version (als Anhang) verknüpft sind
$t->json('template_document_ids')->nullable();
$t->foreignId('created_by')->constrained('users');
$t->timestamps();
$t->unique(['offer_id', 'version_no']);
$t->index(['offer_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('offer_versions');
}
};

View file

@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 3 von 7.
*
* Einzelne Leistungs-/Positionszeilen einer Angebotsversion.
* Hängt an `offer_versions`, NICHT am Offer-Kopf so bleiben
* Positionen einer versendeten Version unveränderlich, während
* eine neue Version ihre eigenen Positionen führt.
*
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
* `metadata` speichert einen Snapshot (Titel, Preis zum Zeitpunkt der
* Erstellung), damit Positionen auch nach einer späteren v2-Migration
* lesbar bleiben (Risiko R4 im Ticket-Dokument).
*
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
*/
return new class extends Migration
{
public function up(): void
{
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);
// Bewusst OHNE FK-Constraint — siehe Risiko R4
$t->unsignedBigInteger('travel_program_id')->nullable();
$t->unsignedBigInteger('fewo_lodging_id')->nullable();
$t->json('metadata')->nullable();
$t->timestamps();
$t->index(['offer_version_id', 'position']);
});
}
public function down(): void
{
Schema::dropIfExists('offer_items');
}
};

View file

@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 4 von 7.
*
* Wiederverwendbare Angebotsvorlagen (Text-/Positions-Blueprints, die
* sich in Phase C in der Admin-UI pflegen lassen). Eine Vorlage liefert
* Default-Texte + Default-Positionen für neue Angebote.
*
* Zusatz: Nachträgliche FK-Verknüpfung `offer_versions.template_id`,
* weil Migration 2 noch nicht auf diese Tabelle verweisen konnte.
*
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('offer_templates', function (Blueprint $t) {
$t->id();
// `branch` existiert schon im CRM — Vorlagen können so pro
// Filiale gepflegt werden. Eine spätere Erweiterung auf
// `organization_id` (Modul 5) erfolgt additiv.
$t->foreignId('branch_id')
->nullable()
->constrained('branch')
->nullOnDelete();
$t->string('name');
$t->text('description')->nullable();
$t->string('default_headline')->nullable();
$t->text('default_intro')->nullable();
$t->longText('default_itinerary')->nullable();
$t->text('default_closing')->nullable();
// Array aus [{title, description, type, price_per_unit, quantity}, …]
$t->json('default_items')->nullable();
$t->boolean('is_active')->default(true);
$t->foreignId('created_by')->constrained('users');
$t->timestamps();
$t->softDeletes();
$t->index(['branch_id', 'is_active']);
});
Schema::table('offer_versions', function (Blueprint $t) {
$t->foreign('template_id')
->references('id')
->on('offer_templates')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('offer_versions', function (Blueprint $t) {
$t->dropForeign(['template_id']);
});
Schema::dropIfExists('offer_templates');
}
};

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 5 von 7.
*
* Dateiablage für Anhänge einer Angebotsversion (freie Uploads, später
* auch PDF-Archivkopien). Struktur ist an `booking_files` angelehnt
* (identifier, filename, dir, original_name, ext, mime, size), damit
* `FileRepository::store()` direkt wiederverwendet werden kann.
*
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('offer_files', function (Blueprint $t) {
$t->id();
$t->foreignId('offer_version_id')
->constrained('offer_versions')
->cascadeOnDelete();
$t->string('identifier', 64)->nullable();
$t->string('filename');
$t->string('dir');
$t->string('original_name');
$t->string('ext', 16);
$t->string('mine', 128);
$t->unsignedBigInteger('size')->default(0);
$t->boolean('include_in_pdf')->default(true);
$t->timestamps();
$t->index(['offer_version_id', 'identifier']);
});
}
public function down(): void
{
Schema::dropIfExists('offer_files');
}
};

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 6 von 7.
*
* Einmal-Token für den kundenseitigen Freigabe-Link
* (/angebot/{token} Phase D). Pro Angebot + Version genau ein aktiver
* Token; bei Neuversand einer neuen Version wird der alte Token
* `revoked` gesetzt. Tokens sind SHA-256-Hashes; der Klartext wird
* ausschließlich im Mail-Link an den Kunden übergeben.
*
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('offer_access_tokens', function (Blueprint $t) {
$t->id();
$t->foreignId('offer_id')
->constrained('offers')
->cascadeOnDelete();
$t->foreignId('offer_version_id')
->constrained('offer_versions')
->cascadeOnDelete();
$t->string('token_hash', 64)->unique();
$t->dateTime('expires_at')->nullable();
$t->dateTime('first_opened_at')->nullable();
$t->dateTime('revoked_at')->nullable();
$t->timestamps();
$t->index(['offer_id', 'revoked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('offer_access_tokens');
}
};

View file

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modul 6 Angebote: Ticket A1 / Migration 7 von 7.
*
* Schließt die FK-Beziehungen, die in den vorherigen Migrationen wegen
* zyklischer Abhängigkeiten nicht direkt gesetzt werden konnten:
*
* 1. `offers.current_version_id` offer_versions.id (ON DELETE SET NULL)
* 2. `bookings.offer_id` offers.id (ON DELETE SET NULL)
*
* Letzteres erlaubt die Conversion „Angebot Buchung" (Ticket B8)
* und zeigt im Buchungsdatensatz, aus welchem Angebot er entstanden ist.
*
* VORBEDINGUNG: alle vorherigen Offers-Migrationen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('offers', function (Blueprint $t) {
$t->foreign('current_version_id')
->references('id')
->on('offer_versions')
->nullOnDelete();
});
Schema::table('booking', function (Blueprint $t) {
// `inquiry_id` kommt aus Modul-3 Phase 2 (war vorher `lead_id`)
$t->unsignedBigInteger('offer_id')->nullable()->after('inquiry_id');
$t->foreign('offer_id')
->references('id')
->on('offers')
->nullOnDelete();
$t->index('offer_id');
});
}
public function down(): void
{
Schema::table('booking', function (Blueprint $t) {
$t->dropForeign(['offer_id']);
$t->dropIndex(['offer_id']);
$t->dropColumn('offer_id');
});
Schema::table('offers', function (Blueprint $t) {
$t->dropForeign(['current_version_id']);
});
}
};

View file

@ -110,7 +110,13 @@ echo " git status (sollte Phase-2 + Offers-Dateien als modifiziert zeigen):"
git status --short | head -40
echo
echo " Verbliebene Phase-2-Marker im Code (sollten nun wieder da sein):"
grep -rn "inquiry_id" app/ 2>/dev/null | head -5 || echo " (keine gefunden — Restore eventuell nicht vollständig)"
PHASE2_HITS=$(grep -rn "inquiry_id" app/ 2>/dev/null | head -5 || true)
if [[ -n "${PHASE2_HITS}" ]]; then
echo "${PHASE2_HITS}"
echo " → OK, Phase-2-Code ist wieder aktiv."
else
echo " (keine gefunden — Restore eventuell nicht vollständig)"
fi
echo
echo "=========================================="

View file

@ -0,0 +1,305 @@
# Phase-2-Live-Deploy — Anleitung
**Ausgangssituation (nach Phase-1-Live-Deploy am 2026-04-17):**
- Live: Phase 1 ✅ (Code + DB-Migrationen 25, 26)
- Test: Phase 1 ✅ + Phase 2 ✅ (Code + DB-Migrationen 2529, verifiziert)
- Workspace (lokal): Phase 1 + Phase 2 + Offers-Code ready
Dieses Handbuch beschreibt, wie Phase 2 vom Test-Stand auf Live übertragen wird. Phase 2 umfasst zwei atomar zusammengehörige Teile plus eine kleine Ergänzung aus Phase 1:
1. **DB-Schema-Änderungen** (3 Migrationen, breaking):
- `RENAME TABLE customer → contacts`
- `RENAME TABLE lead → inquiries`
- Spalte `booking.lead_id → booking.inquiry_id` (inkl. FK-Index-Neuaufbau)
2. **Application-Code-Änderungen** (23 modifizierte Dateien):
- Models `Customer`, `Contact`, `Lead`, `Booking` mit neuen `$table`-Werten bzw. Spaltennamen
- Repositories, Controllers, Services, Commands, Views — alle Referenzen auf `booking.lead_id``inquiry_id` umgestellt; Raw-SQL auf `contacts`/`inquiries` umgestellt
- Smoke-Test-Fixes (2026-04-17): `Lead::bookings()` mit explizitem FK `inquiry_id`; `orderColumn`-SQL in `ContactController`/`ReportController`/`ReportBookingController`/`ReportLeadsController` auf `contacts.*` gezogen.
3. **Phase-1-Hotfixes — nullable Parameter in den Mail-Dir-Services** (3 modifizierte Dateien):
- `app/Services/Booking.php`, `app/Services/Lead.php`, `app/Services/MailDirService.php`
- `int $mailDirId``?int $mailDirId` bei `getCustomerMailName()` / `getCustomerMailEmails()` / `resolveModel()` (behebt Mail-Dialog-Crash wenn `customer_mails.mail_dir_id` / `lead_mails.mail_dir_id` NULL ist).
- `string $subdir``?string $subdir` bei `setOutputDirs()` / `setOutputDir()` (behebt Mail-Dialog-Crash wenn `$mail_sdir_id` NULL ist, z.B. bei Entwürfen oder Top-Level-Ordnern).
- Fachlich eigentlich Phase-1-Bugs (Laravel-10/PHP-8-strict-typing vs. `null`-Werte aus nullable DB-Spalten), die aber erst durch die Test-Durchläufe am 2026-04-17 sichtbar wurden. Wir liefern die Fixes zusammen mit Phase 2 aus, weil ein zweites separates Wartungsfenster für drei Service-Dateien nicht sinnvoll wäre und die Änderungen keinerlei DB-Abhängigkeit haben (also auch außerhalb der DB-Migration risikoarm sind).
> **Wichtig:** DB und Phase-2-Code müssen atomar gemeinsam live gehen. Zwischen den beiden gibt es kein kompatibles Fenster, deswegen ist ein **Maintenance-Window von ~1020 min Pflicht**. Die 3 Phase-1-Hotfix-Services werden im selben rsync-Schritt mit hochgeladen.
---
## Vorbereitung (am Tag vor dem Deploy)
1. **Timing**: Wartungsfenster einplanen, idealerweise außerhalb der Geschäftszeiten. ~20 Minuten reichen erfahrungsgemäß.
2. **Deploy-Freigabe**: Phase 1 auf Live läuft stabil (mindestens 2448 h), keine Regressions-Meldungen.
3. **Team-Kommunikation**: Mitarbeiter:innen informieren — während des Wartungsfensters keine Mails, keine Buchungen anlegen, keine PDFs drucken.
### Vorab-Kontrolle auf Live (lesend, keine Änderungen)
```bash
# Live-Server: aktueller Stand?
php artisan --version
# → sollte 10.50+ sein (Phase 1 hat Laravel 10 gebracht)
php artisan migrate:status | grep phase1
# → sollte beide phase1-Migrationen mit "Ran" zeigen
mysql -e "SHOW TABLES LIKE 'customer';"
mysql -e "SHOW TABLES LIKE 'lead';"
# → beide müssen noch existieren (Phase 2 NICHT ausgeführt)
mysql -e "SHOW COLUMNS FROM booking LIKE 'lead_id';"
# → lead_id muss existieren
```
Wenn einer dieser Checks nicht das erwartete Ergebnis liefert: **STOP**, klären bevor weitergemacht wird.
### Smoke-Tests auf Test (am Deploy-Tag, vor dem Live-Deploy)
Diese Liste einmal komplett auf `https://mein.sterntours.test` durchgehen. Wenn einer fehlschlägt → Fehler fixen, Live-Deploy verschieben.
- [ ] Login funktioniert, Dashboard lädt
- [ ] **Buchungen**: `/booking` lädt, Filter- und Sortier-Funktionen arbeiten, Anfrage-Nr-Spalte zeigt korrekte Werte
- [ ] **Buchungsdetail** öffnen, alle Tabs laden (Buchung, Mails, Notizen, Dokumente)
- [ ] **PDF-Generierung** aus einer Buchung: Buchungsauftrag, Reisebestätigung, Voucher (Kunde + Agentur), Storno
- [ ] **Anfragen**: `/lead` lädt, einzelne Anfrage öffnen
- [ ] **Anfrage → Buchung erzeugen** (legt `booking.inquiry_id` korrekt an)
- [ ] **Kontakte**: `/contacts` lädt, einzelner Kontakt öffnet, `leads_count` + `bookings_count` zeigen korrekte Werte (testet die Beziehungen über die neuen Tabellennamen)
- [ ] **Kontakt-Duplikate**: `/contact/duplicates` lädt, Zähler stimmen
- [ ] **Mail-Versand** aus einer Buchung (Eintrag in `customer_mails.lead_id` muss identisch zur `booking.inquiry_id` sein)
- [ ] **Mail-Dialog öffnen** bei Buchung und Anfrage (Modal "Neue Mail schreiben" / "Mail anzeigen") — testet den nullable-`mail_dir_id`-Fix in `Services\Booking::getCustomerMailName()` / `Services\Lead::getCustomerMailName()`.
- [ ] **Admin-Reports**: `/admin/report`, `/admin/report-bookings`, `/admin/report-provider`, `/admin/report-leads` laden UND jeweils einmal nach Kundenname sortieren (testet die `orderColumn`-Fixes für `contacts.firstname/name`) + CSV-Export
- [ ] **BookingImport über API** (falls testbar — feuert gegen `/api/booking/import`) legt Datensatz mit korrekter `inquiry_id` an
- [ ] Newsletter-Sync-Command als Dry-Run: `php artisan newsletter:sync-kulturreisen --dry-run` (falls implementiert)
---
## Deploy-Ablauf Live
### Schritt 1 — Upload-Liste generieren (lokal)
Der Phase-2-Delta gegen Phase 1: **3 neue Migrationen + 24 modifizierte Dateien (Phase 2) + 3 Phase-1-Hotfix-Services = 30 Dateien.** Exakte Liste:
```bash
cd /workspace/mein.sterntours.de
cat > /tmp/phase2-files-to-upload.txt <<'EOF'
database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
app/Models/Booking.php
app/Models/Customer.php
app/Models/Contact.php
app/Models/Lead.php
app/Repositories/BookingPDFRepository.php
app/Repositories/LeadRepository.php
app/Repositories/CustomerMailRepository.php
app/Http/Controllers/RequestController.php
app/Http/Controllers/API/BookingController.php
app/Http/Controllers/Admin/ReportController.php
app/Http/Controllers/Admin/ReportBookingController.php
app/Http/Controllers/Admin/ReportProviderController.php
app/Http/Controllers/Admin/ReportLeadsController.php
app/Http/Controllers/LeadController.php
app/Http/Controllers/CustomerController.php
app/Http/Controllers/ContactController.php
app/Services/BookingImport.php
app/Services/Booking.php
app/Services/Lead.php
app/Services/MailDirService.php
app/Console/Commands/SyncNewsletterKulturreisen.php
app/Console/Commands/ContactsFindDuplicates.php
app/Console/Commands/ContactsMergeDuplicates.php
resources/views/customer/mail/modal-show-mail-inner.blade.php
resources/views/contact/index.blade.php
resources/views/pdf/components/booking_head.blade.php
resources/views/pdf/components/booking_header.blade.php
EOF
wc -l /tmp/phase2-files-to-upload.txt
# → sollte 30 zeigen (3 Migrationen + 24 Phase-2-Code + 3 Phase-1-Hotfix-Services)
```
**Erklärung zu den zusätzlichen Dateien gegenüber einer rein strukturellen Phase-2-Liste:**
- `ReportBookingController.php` — Smoke-Test-Fix am 2026-04-17: `orderColumn('customer.firstname/name', …)``contacts.*`. War schon vor dem Fix nicht perfekt, fiele aber beim ersten Sortier-Klick nach Phase-2-Rename hart auf.
- `resources/views/contact/index.blade.php` — Smoke-Test-Fix am 2026-04-17: DataTables-Column-Definition `name: 'customer.id'``name: 'contacts.id'` (+ passender `orderColumn('contacts.id', …)` / `filterColumn('contacts.id', …)` im `ContactController`). Yajra verwendet `name:` als raw SQL-Spalte, wenn kein `orderColumn` matched — deswegen muss der Identifier zwischen Blade und Controller konsistent sein und dem neuen Tabellennamen entsprechen.
- `app/Services/Booking.php`, `Lead.php`, `MailDirService.php` — Phase-1-Hotfix: nullable-Parameter (`?int $mailDirId`, `?string $subdir`). Behebt Laravel-10/PHP-8-strict-typing-Fehler bei Mail-Dialogen mit NULL-Werten aus `customer_mails` / `lead_mails`.
> **Ausdrücklich NICHT mit hochladen:** Phase-3-/Phase-4-Migrationen (5+6 Stück), Offers-Migrationen (7 Stück), Offer-Models (6 Stück), `config/filesystems.php` (offer-Disk). Diese kommen erst mit späteren Modulen und würden auf Live ungewollt Tabellen anlegen oder Code-Erwartungen brechen.
### Schritt 1b — Upload-Liste gegen den Workspace verifizieren (Sanity-Check)
Damit sich hier kein Tippfehler einschleicht, einmal prüfen, dass alle 29 Einträge tatsächlich im Workspace existieren:
```bash
cd /workspace/mein.sterntours.de
MISSING=0
while read f; do
if [[ ! -f "$f" ]]; then
echo "FEHLT: $f"
MISSING=$((MISSING+1))
fi
done < /tmp/phase2-files-to-upload.txt
echo "---"
echo "Fehlend: $MISSING (sollte 0 sein)"
echo "Gesamt: $(wc -l < /tmp/phase2-files-to-upload.txt)"
```
### Schritt 2 — DB-Backup auf Live
```bash
# Auf dem Live-Server, vor dem Deploy
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
mysqldump --single-transaction --routines --triggers \
--databases DEIN_DB_NAME \
| gzip > /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz
# Größe prüfen:
ls -lh /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz
```
Falls `mysqldump` nicht zur Hand: das Hoster-Tool nutzen (phpMyAdmin-Export, Plesk/cPanel-Backup-Feature, etc.).
### Schritt 3 — Maintenance-Mode an
```bash
cd /pfad/zu/mein.sterntours.de
php artisan down --render="errors::503" --secret="phase-2-deploy-$(date +%s)"
# → Secret-URL wird ausgegeben, z.B.:
# https://domain/phase-2-deploy-1746123456
# Damit kannst du während der Wartung selbst noch als Admin auf die Seite.
```
### Schritt 4 — Upload per rsync
```bash
# Vom lokalen Workspace (oder Test-Server) auf Live
rsync -av --files-from=/tmp/phase2-files-to-upload.txt \
/workspace/mein.sterntours.de/ \
user@live-server:/pfad/zu/mein.sterntours.de/
```
Alternative mit scp, falls rsync nicht verfügbar:
```bash
while read f; do
scp "/workspace/mein.sterntours.de/$f" "user@live-server:/pfad/zu/mein.sterntours.de/$f"
done < /tmp/phase2-files-to-upload.txt
```
### Schritt 5 — Code-Caches leeren
Auf dem Live-Server:
```bash
cd /pfad/zu/mein.sterntours.de
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear
composer dump-autoload --optimize
```
### Schritt 6 — DB-Migrationen gezielt ausführen
> **Wichtig: KEIN `php artisan migrate` ohne `--path`!**
> Wenn man nacktes `migrate` aufruft, werden auch die im Workspace liegenden Phase-3-, Phase-4- und Offer-Migrationen ausgeführt, die noch nicht deployreif sind. Deswegen gezielt per `--path`:
```bash
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
# Verifikation
php artisan migrate:status | grep phase2
# → alle drei sollten jetzt "Ran" zeigen
```
Erwartete Dauer: wenige Sekunden. `RENAME TABLE` ist in MySQL eine Metadaten-Operation und läuft unabhängig von der Tabellengröße fast instantan. Der Spaltenrename `booking.lead_id → inquiry_id` ist je nach MySQL-Version ebenfalls schnell (MySQL 8+ mit `ALGORITHM=INSTANT`) oder dauert ein paar Sekunden (mit Fallback).
### Schritt 7 — Production-Caches neu aufbauen
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
```
### Schritt 8 — Maintenance-Mode aus
```bash
php artisan up
```
### Schritt 9 — Live-Smoke-Tests
Dieselbe Checkliste wie bei Test (oben). Besonders aufpassen:
- PDF-Erzeugung (Voucher einer aktuellen Buchung)
- Mail-Versand aus einer Buchung
- Anfrage-Erstellung + daraus Buchung ableiten
- `/contacts/duplicates` öffnen — zeigt korrekte Counts
Bei Problemen sofort zu **Rollback-Plan** (unten) wechseln.
---
## Rollback-Plan (falls Phase 2 auf Live scheitert)
Der Rollback muss ebenfalls atomar passieren: **Code und DB zusammen zurück.**
### Schritt A — Maintenance-Mode an
```bash
php artisan down --render="errors::503"
```
### Schritt B — DB zurückrollen
Option 1 (sauber, per Migration):
```bash
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
```
(Die Migrationen haben symmetrische `down()`-Methoden, die die Renames rückgängig machen.)
Option 2 (Notfall, falls `migrate:rollback` fehlschlägt):
```bash
gunzip < /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz | mysql DEIN_DB_NAME
```
### Schritt C — Code zurück
Entweder die pre-Phase-2-Versionen der 22 Dateien zurückspielen (z.B. aus dem DB-Backup-Zeitpunkt-Dateisystem-Backup), oder mit git Revert-Commit:
```bash
# Wenn Live-Server ein Git-Checkout ist:
git checkout <phase-1-commit-id> -- app/ resources/ config/
```
### Schritt D — Caches + Up
```bash
php artisan cache:clear && php artisan config:clear && php artisan view:clear && php artisan route:clear
composer dump-autoload
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan up
```
---
## Dokumentation nach erfolgreichem Live-Deploy
1. `dev/customer-bookings/umsetzung.md` updaten: Phase 2 auf Live per 2026-MM-DD.
2. Dieses Handbuch als "durchgeführt" markieren, ggf. Learnings ergänzen.
3. Git-Commit auf Live-Server (falls Git-basiert): mit Tag `deploy/phase-2-live-2026-MM-DD`.
4. Nächster Schritt: Entscheidung, wann Phase 3 (Participants-Unification), Phase 4 (Communications-Unification) oder das Offers-Modul an die Reihe kommen.
---
## Hintergrund: warum das Maintenance-Window nötig ist
Zwischen DB-Rename und Code-Deploy ist der Zustand inkompatibel:
| Zustand | DB hat | Code erwartet | Resultat |
|---|---|---|---|
| Vor Deploy | `customer`, `lead`, `booking.lead_id` | `customer`, `lead`, `booking.lead_id` | OK |
| DB-migriert, Code alt | `contacts`, `inquiries`, `booking.inquiry_id` | `customer`, `lead`, `booking.lead_id` | **Fehler: Table doesn't exist** |
| DB alt, Code neu | `customer`, `lead`, `booking.lead_id` | `contacts`, `inquiries`, `booking.inquiry_id` | **Fehler: Table doesn't exist** |
| Nach Deploy | `contacts`, `inquiries`, `booking.inquiry_id` | `contacts`, `inquiries`, `booking.inquiry_id` | OK |
Deswegen: Maintenance-Mode an → Code + DB gleichzeitig → Maintenance-Mode aus. Alles innerhalb eines Fensters, in dem keine Requests an die App laufen.

View file

@ -1,8 +1,10 @@
# Umsetzung: Neustrukturierung Customer / Lead / Booking
**Status:** Phase 1 auf Testsystem abgeschlossen — Contacts-Modul live; **Phase-2-App-Code vorbereitet (deploy-bereit)**
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Phase 2 auf **Test** erfolgreich migriert und verifiziert — bereit für Live-Deploy.
**Erstellt:** April 2025
**Letzte Aktualisierung:** 2026-04-17
**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)
---
@ -10,12 +12,70 @@
| Phase | Status | Deployed auf Test? | Deployed auf Live? |
|-------|--------|-------------------|-------------------|
| Phase 1 — Contact-Deduplizierung | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
| Phase 1 — Contacts-Modul (neuer Code) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
| Phase 2 — App-Code (Models/Repos/Controller/Views auf `contacts`/`inquiries`/`inquiry_id`) | ✅ Abgeschlossen | ⬜ Nein | ⬜ Nein |
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ⬜ Ausstehend (Code ist deploy-ready) | ⬜ Nein | ⬜ Nein |
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
| Phase 1 — Contact-Deduplizierung | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-17** |
| Phase 1 — Contacts-Modul (neuer Code) | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-17** |
| Phase 2 — App-Code (Models/Repos/Controller/Views auf `contacts`/`inquiries`/`inquiry_id`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (Deploy-Handbuch fertig) |
| 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) |
| 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 |
### Verifikation Phase 2 auf Test (2026-04-17)
Smoke-Queries nach erfolgter Migration:
```
Tabellen:
customer NOT FOUND ← umbenannt ✓
contacts EXISTS ← neu ✓
lead NOT FOUND ← umbenannt ✓
inquiries EXISTS ← neu ✓
booking EXISTS ✓
booking-Spalten:
lead_id NOT FOUND ← umbenannt ✓
inquiry_id EXISTS ← neu ✓
Eloquent-Queries:
Contact::count() = 19.156 (ohne merged)
Customer::count() = 22.283 (alle, inkl. merged — Legacy-Model)
Lead::count() = 19.543
Booking::count() = 10.648
Booking->inquiry_id funktioniert
Booking->lead() Relation mit expl. FK inquiry_id funktioniert
```
### Smoke-Test-Findings 2026-04-17 (Test-Durchlauf nach Phase-2-Restore)
Während der UI-Smoke-Tests auf `mein.sterntours.test` traten drei unterschiedliche Bug-Klassen auf, alle auf Test gefixt und in die Phase-2-Deploy-Liste aufgenommen.
**1. Eloquent-Relation ohne expliziten FK — `Lead::bookings()`**
- Fehler: `/leads``Column not found: 'booking.lead_id'`.
- Ursache: `$this->hasMany(Booking::class)` ohne 2. Argument ⇒ Laravel leitet den FK aus dem Model-Namen ab (`Lead``lead_id`), der nach dem Rename nicht mehr existiert.
- Fix: `app/Models/Lead.php` Zeile 249253: `$this->hasMany(Booking::class, 'inquiry_id')`.
**2. Hartcodierte Tabellenqualifier in `orderColumn`-SQL-Strings + Blade-Column-`name:`**
- Fehler A: `/contacts``Column not found: 'customer.id' in 'order clause'`. Behoben durch SQL-Umstellung im Controller — blieb aber bestehen, weil Yajra bei fehlendem `orderColumn`-Match den `name:`-String aus der Blade-Column-Definition als raw SQL verwendet.
- Fehler B (gleiche Ursache, 2. Runde): `/contacts` → derselbe Fehler trotz Controller-Fix. Root Cause: In `resources/views/contact/index.blade.php` Zeile 188 stand `name: 'customer.id'`. Yajra matched `orderColumn()` auf diesen `name:`-String — mein erster Fix mit `orderColumn('id', …)` hat also nicht getroffen.
- Ursache zusammengefasst: Yajra-DataTables-`orderColumn()` bekommt als 2. Argument eine Raw-SQL-Expression; **und** der `name:`-Wert einer DataTable-Column muss exakt zum 1. Argument von `orderColumn()` / `filterColumn()` passen — sonst fällt Yajra zurück auf "Name wörtlich als SQL einsetzen".
- Fix in 5 Dateien:
- `app/Http/Controllers/ContactController.php``orderColumn('contacts.id' / 'deleted_at', …)`, `filterColumn('contacts.id', …)`
- `resources/views/contact/index.blade.php` — Column-Name `customer.id``contacts.id`
- `app/Http/Controllers/Admin/ReportController.php` — 2 Blöcke (`customer.firstname $1``contacts.firstname $1` etc.)
- `app/Http/Controllers/Admin/ReportBookingController.php` — 2 Blöcke
- `app/Http/Controllers/Admin/ReportLeadsController.php``$orderByNum`-Array
- Bewusst nicht angefasst: die 1. Argumente von `addColumn` / `rawColumns` (`customer.fullName`, `lead.status_id`) — das sind reine DataTables-Frontend-Identifier, die Yajra über den Eloquent-Relation-Mechanismus auflöst (Model `Customer`/`Lead``$table = 'contacts'`/`'inquiries'`), und die 3-teiligen `booking.customer.firstname`-Strings in `ReportController.php` und `ReportProviderController.php` (Yajra-Relation-Notation bzw. seit jeher kein gültiges SQL, durch Phase 2 nicht schlimmer).
**3. Strict-Type-Regression aus Phase 1 (Laravel-10/PHP-8) — zwei Varianten**
- Fehler A: `App\Services\Booking::getCustomerMailName(): Argument #2 ($mailDirId) must be of type int, null given` in `modal-show-mail-inner.blade.php` Zeile 92.
- Fehler B: `App\Services\Booking::setOutputDirs(): Argument #2 ($subdir) must be of type string, null given` in derselben kompilierten View, Zeile 101.
- Ursache: Blade-Views übergeben `$mail_dir_id` bzw. `$mail_sdir_id` direkt aus DB-Feldern, die nullable sein können (Entwurfs-Mails, Top-Level-Ordner, Mails ohne Subdir). Unter Laravel 8 / PHP 7 wurde `null` stillschweigend zu `0` / `""` gecastet; PHP 8 strict types verweigern das.
- Fix in drei Dateien:
- `app/Services/Booking.php``getCustomerMailName()`, `getCustomerMailEmails()`: `int $mailDirId``?int`; `setOutputDirs()`: `string $subdir``?string`.
- `app/Services/Lead.php` — identische Änderungen (symmetrische API).
- `app/Services/MailDirService.php``getCustomerMailName()`, `getCustomerMailEmails()`, `resolveModel()` auf `?int`; `setOutputDir()` auf `?string`; in `resolveModel()` zusätzlich ein früher `return null`-Guard.
- Das ist **fachlich ein Phase-1-Bug**, der nichts mit Customer→Contacts zu tun hat. Wir deployen ihn trotzdem zusammen mit Phase 2, weil die Services keine DB-Abhängigkeit haben und ein separates Wartungsfenster unnötig wäre.
### Was in Phase 1 umgesetzt wurde
@ -178,33 +238,46 @@ php artisan migrate:rollback --path=database/migrations/2025_04_15_100001_phase1
- Routen-Namen (`lead_detail`, `lead_index`) — bleiben als Aliase, um Links in Views/Mails/Logs nicht zu brechen.
### Schritt 1: Migrationen einspielen
**Auf Test (2026-04-17 erfolgreich durchgeführt, Batch 2729):**
```bash
# Backup erstellen!
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
```
> **Wichtig — `--path` verwenden!** Ohne `--path` würde `php artisan migrate` auch die noch im Workspace liegenden Phase-3-, Phase-4- und Offers-Migrationen ausführen, die noch nicht deployreif sind. Einzeln per `--path` hält den Deploy auf exakt diese drei Dateien beschränkt.
**Auf Live:** Ablauf siehe [phase-2-live-deploy.md](phase-2-live-deploy.md). Der Live-Deploy muss innerhalb eines Wartungsfensters passieren, weil DB und Code atomar zusammengehen müssen.
### Ergebnis-Prüfung
```sql
-- Tabellen vorhanden?
SHOW TABLES LIKE 'contacts';
SHOW TABLES LIKE 'inquiries';
-- Spalte umbenannt?
SHOW COLUMNS FROM booking LIKE 'inquiry_id';
-- FK vorhanden?
SELECT * FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_NAME = 'booking' AND COLUMN_NAME = 'inquiry_id';
```
Oder via Laravel-Tinker:
```bash
php artisan tinker --execute="
echo Schema::hasTable('contacts') ? 'contacts OK' : 'contacts FEHLT'; echo PHP_EOL;
echo Schema::hasTable('inquiries') ? 'inquiries OK' : 'inquiries FEHLT'; echo PHP_EOL;
echo Schema::hasColumn('booking', 'inquiry_id') ? 'booking.inquiry_id OK' : 'FEHLT'; echo PHP_EOL;
"
```
### Rollback Phase 2
```bash
# In umgekehrter Reihenfolge
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
```
> **Wichtig:** Rollback MUSS zusammen mit Code-Revert passieren (Code erwartet sonst die falschen Tabellennamen). Details im [phase-2-live-deploy.md](phase-2-live-deploy.md#rollback-plan-falls-phase-2-auf-live-scheitert).
---
## Phase 3 — Participants konsolidieren

View file

@ -121,6 +121,25 @@
</div>
</div>
@php
$priceTotalStored = $booking->getPriceTotalRaw();
$priceTotalComputed = round((float) $booking->getPriceRaw() + (float) $booking->getServiceTotal(true), 2);
@endphp
<div class="col-12">
<p class="small text-muted border rounded px-3 py-2 mb-3 bg-light">
<strong>price_total</strong> (gespeichert in DB):
{{ \App\Services\Util::_number_format($priceTotalStored ?? 0) }}&nbsp;
<span class="mx-2">·</span>
<strong>rechnerisch</strong> (Organisation + Vermittlung):
{{ \App\Services\Util::_number_format($priceTotalComputed) }}&nbsp;
@if($booking->isCanceled() && $booking->getPriceCanceledRaw() !== null)
<span class="mx-2">·</span>
<strong>laut Storno-Logik</strong> (= Storno-Betrag <code>price_canceled</code>):
{{ \App\Services\Util::_number_format((float) $booking->getPriceCanceledRaw()) }}&nbsp;
@endif
</p>
</div>
<div class="col-12">
<hr>
<div class="text-left mt-3">

View file

@ -185,7 +185,7 @@
},
{
data: 'id',
name: 'customer.id'
name: 'contacts.id'
},
{
data: 'firstname',

View file

@ -45,7 +45,7 @@
<p><strong>Kunde: </strong>
{{ $customer_mail->customer->salutation->name }} {{ $customer_mail->customer->title }} {{ $customer_mail->customer->firstname }} {{ $customer_mail->customer->name }}
@if($customer_mail->booking)
({{$customer_mail->booking->lead_id}})
({{$customer_mail->booking->inquiry_id}})
@endif
</p>
@endif

View file

@ -22,7 +22,7 @@
{{-- @endif --}}
</td>
<td align="left" style="color: #000; padding:0.5rem">
Buchungsnummer: <strong>{{ $booking->lead_id }}</strong><br />
Buchungsnummer: <strong>{{ $booking->inquiry_id }}</strong><br />
Buchungsdatum: <strong>{{ _format_date($booking->booking_date) }}</strong><br /><br />
Reisetermin: <strong>{{ _format_date($booking->start_date) }} -
{{ _format_date($booking->end_date) }}</strong><br />

View file

@ -35,7 +35,7 @@
</tr>
<tr>
<td>Buchungsnummer:</td>
<td><strong>{{ $booking->lead_id }}</strong></td>
<td><strong>{{ $booking->inquiry_id }}</strong></td>
</tr>
<tr>
<td>Buchungsdatum:</td>