diff --git a/.env b/.env index 8aae097..904c04d 100755 --- a/.env +++ b/.env @@ -16,6 +16,7 @@ APP_DOMAIN_TLD=test LOG_CHANNEL=stack SUCCESS_KEY=f6077389c9ce710e554763a5de02c8ec +EXCEPTION_MAIL=exception@adametz.media # Standard Database Connection DB_CONNECTION=mysql diff --git a/app/Console/Commands/BookingsAuditStornoPriceTotal.php b/app/Console/Commands/BookingsAuditStornoPriceTotal.php new file mode 100644 index 0000000..e7a6478 --- /dev/null +++ b/app/Console/Commands/BookingsAuditStornoPriceTotal.php @@ -0,0 +1,156 @@ +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, '/')); + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 59c585d..ca9146f 100755 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,7 +2,16 @@ namespace App\Exceptions; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Session\TokenMismatchException; +use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\ValidationException; +use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; class Handler extends ExceptionHandler @@ -37,6 +46,65 @@ class Handler extends ExceptionHandler public function report(Throwable $exception) { parent::report($exception); + + $e = $this->mapException($exception); + + if ($this->shouldMailServerError($e)) { + $this->sendEmail($e); + } + } + + /** + * E-Mail nur bei echten Serverfeilern (5xx bzw. unbehandelte Exceptions), nicht bei lokalem Entwickeln. + */ + protected function shouldMailServerError(Throwable $e): bool + { + if (app()->environment('local', 'testing')) { + return false; + } + + if (! config('app.exception_mail')) { + return false; + } + + return $this->exceptionIndicatesServerError($e); + } + + /** + * Entspricht dem, was üblicherweise als HTTP500 ausgeliefert würde. + */ + protected function exceptionIndicatesServerError(Throwable $e): bool + { + if ( + $e instanceof AuthenticationException + || $e instanceof AuthorizationException + || $e instanceof ModelNotFoundException + || $e instanceof ValidationException + || $e instanceof TokenMismatchException + ) { + return false; + } + + if ($e instanceof HttpExceptionInterface) { + return $e->getStatusCode() >= 500; + } + + return true; + } + + protected function exceptionMailContextLine(): string + { + if (app()->runningInConsole()) { + $argv = $_SERVER['argv'] ?? []; + + return 'CLI: ' . (count($argv) ? implode(' ', $argv) : php_sapi_name()); + } + + if (app()->bound('request') && request()) { + return request()->fullUrl(); + } + + return 'n/a'; } /** @@ -52,4 +120,28 @@ class Handler extends ExceptionHandler { return parent::render($request, $exception); } + + public function sendEmail(Throwable $exception) + { + try { + $e = FlattenException::create($exception); + $handler = new HtmlErrorRenderer(true); + $css = $handler->getStylesheet(); + $content = $handler->getBody($e); + $to = config('app.exception_mail'); + $subject = config('app.name') . ' Exception: ' . $this->exceptionMailContextLine(); + + if ($to) { + Mail::send('emails.exception', compact('css', 'content'), function ($message) use ($to, $subject) { + $message->to($to)->subject($subject); + }); + } + } catch (Throwable $ex) { + file_put_contents( + storage_path('logs/laravel-' . date('Y-m-d') . '.log'), + '[' . date('Y-m-d H:i:s') . '] exception-handler-error: ' . $ex->getMessage() . "\n", + FILE_APPEND + ); + } + } } diff --git a/app/Http/Controllers/Admin/ReportBookingController.php b/app/Http/Controllers/Admin/ReportBookingController.php index 6579c4c..0c518e9 100644 --- a/app/Http/Controllers/Admin/ReportBookingController.php +++ b/app/Http/Controllers/Admin/ReportBookingController.php @@ -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); diff --git a/app/Http/Controllers/Admin/ReportController.php b/app/Http/Controllers/Admin/ReportController.php index ab45f3d..033d8b9 100755 --- a/app/Http/Controllers/Admin/ReportController.php +++ b/app/Http/Controllers/Admin/ReportController.php @@ -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); @@ -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); diff --git a/app/Http/Controllers/Admin/ReportLeadsController.php b/app/Http/Controllers/Admin/ReportLeadsController.php index ddfe25a..232ddee 100644 --- a/app/Http/Controllers/Admin/ReportLeadsController.php +++ b/app/Http/Controllers/Admin/ReportLeadsController.php @@ -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', diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php index effb97d..3ddedc9 100644 --- a/app/Http/Controllers/ContactController.php +++ b/app/Http/Controllers/ContactController.php @@ -271,9 +271,9 @@ 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('contacts.id', 'LIKE', '%' . $keyword . '%'); } diff --git a/app/Models/Booking.php b/app/Models/Booking.php index f3bf11c..80811b3 100644 --- a/app/Models/Booking.php +++ b/app/Models/Booking.php @@ -787,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); @@ -797,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']); diff --git a/app/Models/Lead.php b/app/Models/Lead.php index f9441f5..f7ddf33 100644 --- a/app/Models/Lead.php +++ b/app/Models/Lead.php @@ -108,16 +108,16 @@ use Illuminate\Database\Eloquent\Collection; */ class Lead extends Model { - use HasFactory; + use HasFactory; - protected $connection = 'mysql'; + protected $connection = 'mysql'; - /** - * 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'; + /** + * 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', @@ -126,8 +126,8 @@ class Lead extends Model 'travelagenda_id' => 'int', 'sf_guard_user_id' => 'int', 'is_closed' => 'bool', - 'is_rebook' => 'bool', - 'initialcontacttype_id' => 'int', + 'is_rebook' => 'bool', + 'initialcontacttype_id' => 'int', 'searchengine_id' => 'int', 'status_id' => 'int', 'website_id' => 'int', @@ -154,7 +154,7 @@ class Lead extends Model 'remarks', 'sf_guard_user_id', 'is_closed', - 'is_rebook', + 'is_rebook', 'initialcontacttype_id', 'searchengine_id', 'searchengine_keywords', @@ -169,24 +169,24 @@ class Lead extends Model 'participant_birthdate', 'participant_salutation_id' ]; - protected $passolutionPDFs = []; - + 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){ - $carbon = Carbon::now(); - }else{ + if (!$date) { + $carbon = Carbon::now(); + } 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() { return $this->belongsTo(Customer::class); @@ -227,18 +227,18 @@ class Lead extends Model return $this->belongsTo(TravelCategory::class, 'travelcategory_id'); } - //on crm - public function travel_country_crm() - { - return $this->belongsTo('App\Models\Sym\TravelCountry', 'travelcountry_id', 'id'); - } + //on crm + public function travel_country_crm() + { + return $this->belongsTo('App\Models\Sym\TravelCountry', 'travelcountry_id', 'id'); + } - //on stern other DB - public function travel_country() - { - return $this->belongsTo('App\Models\TravelCountry', 'travelcountry_id', 'crm_id'); - } + //on stern other DB + public function travel_country() + { + return $this->belongsTo('App\Models\TravelCountry', 'travelcountry_id', 'crm_id'); + } public function website() @@ -248,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() @@ -272,149 +273,156 @@ class Lead extends Model } public function lead_files() - { + { //no lead_mail_id - return $this->hasMany(LeadFile::class, 'lead_id')->where('lead_mail_id', null); - } + return $this->hasMany(LeadFile::class, 'lead_id')->where('lead_mail_id', null); + } - public function lead_mails() - { - return $this->hasMany(LeadMail::class, 'lead_id', 'id'); - } + public function lead_mails() + { + return $this->hasMany(LeadMail::class, 'lead_id', 'id'); + } - public function lead_mails_sent_at() - { - return $this->hasMany(LeadMail::class, 'lead_id')->orderBy('sent_at', 'ASC'); - } + public function lead_mails_sent_at() + { + return $this->hasMany(LeadMail::class, 'lead_id')->orderBy('sent_at', 'ASC'); + } - public function lead_mail_last() - { - return $this->hasOne(LeadMail::class, 'lead_id')->latest(); - } + public function lead_mail_last() + { + return $this->hasOne(LeadMail::class, 'lead_id')->latest(); + } - public function lead_notices() - { - return $this->hasMany(LeadNotice::class, 'lead_id')->orderBy('updated_at', 'DESC'); - } + public function lead_notices() + { + return $this->hasMany(LeadNotice::class, 'lead_id')->orderBy('updated_at', 'DESC'); + } - public static function getSfGuardUserArray(){ - return SfGuardUser::where('is_active', 1)->get()->pluck('fullname', 'id'); - } + 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; + 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; - + 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; + 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; + return $emtpy ? $Status->prepend('-', 0) : $Status; } 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 = ' '; } - if($this->status_id == 14 && !$this->is_rebook){ - $icon = ' '; + if ($this->status_id == 14 && !$this->is_rebook) { + $icon = ' '; } - if($this->status_id == 15){ + if ($this->status_id == 15) { $icon = ' '; - if($booking && $booking->lawyer_date){ - return ''.$icon.$booking->lawyer_date->format('d.m.Y').''; + if ($booking && $booking->lawyer_date) { + return '' . $icon . $booking->lawyer_date->format('d.m.Y') . ''; } } - return ''.$icon.$this->status->name.''; + return '' . $icon . $this->status->name . ''; } return '-'; } - public function getTravelCountryDestco($badge = true){ + public function getTravelCountryDestco($badge = true) + { $out = ""; - if($this->bookings->count()){ + if ($this->bookings->count()) { $out .= $badge ? '' : ''; - 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 ? '' : ''; return $out; } - if($this->travel_country){ - return $badge ? ''.$this->travel_country->destco.'' : $this->travel_country->destco; + if ($this->travel_country) { + return $badge ? '' . $this->travel_country->destco . '' : $this->travel_country->destco; } return "-"; } - public function countLeadMailsBy($dir, $subdir=false){ - if($dir === 11){ - return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count(); - } - if($subdir){ - return $this->lead_mails->where('dir', $dir)->where('subdir', $subdir)->count(); - } - return $this->lead_mails->where('dir', $dir)->count(); - } + public function countLeadMailsBy($dir, $subdir = false) + { + if ($dir === 11) { + return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count(); + } + 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 = []; + $nats = []; - if(count($this->passolutionPDFs)){ - return $this->passolutionPDFs; - } + if (count($this->passolutionPDFs)) { + return $this->passolutionPDFs; + } - if(!$this->travel_country){ - return $this->passolutionPDFs; - } - - $destco = $this->travel_country->destco; + if (!$this->travel_country) { + return $this->passolutionPDFs; + } + + $destco = $this->travel_country->destco; //default no travel_nationality $nats['de'] = 'de'; - - 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)){ - $nats['de'] = 'de'; - } - foreach ($nats as $nat){ - $data = [ - 'nat' => $nat, - 'destco' => $destco, - ]; - $passolution = new Passolution($data); - $this->passolutionPDFs[] = $passolution->findOrCreatePDF($create, $resync); - } - return $this->passolutionPDFs; - } - public function resyncPassolutionPDF(){ - return $this->getPassolutionPDF(true, true); - } + 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)) { + $nats['de'] = 'de'; + } + foreach ($nats as $nat) { + $data = [ + 'nat' => $nat, + 'destco' => $destco, + ]; + $passolution = new Passolution($data); + $this->passolutionPDFs[] = $passolution->findOrCreatePDF($create, $resync); + } + return $this->passolutionPDFs; + } + + public function resyncPassolutionPDF() + { + return $this->getPassolutionPDF(true, true); + } } diff --git a/app/Repositories/BookingRepository.php b/app/Repositories/BookingRepository.php index 7c4838d..be49e03 100644 --- a/app/Repositories/BookingRepository.php +++ b/app/Repositories/BookingRepository.php @@ -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; diff --git a/app/Services/Booking.php b/app/Services/Booking.php index f831388..5ad24aa 100644 --- a/app/Services/Booking.php +++ b/app/Services/Booking.php @@ -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); } diff --git a/app/Services/Lead.php b/app/Services/Lead.php index e6b47dd..6cbd91b 100644 --- a/app/Services/Lead.php +++ b/app/Services/Lead.php @@ -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); } diff --git a/app/Services/MailDirService.php b/app/Services/MailDirService.php index f36cec6..47b5a35 100644 --- a/app/Services/MailDirService.php +++ b/app/Services/MailDirService.php @@ -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 */ - 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), diff --git a/bootstrap/cache/config.php b/bootstrap/cache/config.php deleted file mode 100644 index 66c2e87..0000000 --- a/bootstrap/cache/config.php +++ /dev/null @@ -1,1729 +0,0 @@ - - array ( - 'name' => 'STERN TOURS CRM', - 'env' => 'local', - 'debug' => true, - 'url' => 'https://mein.sterntours.test', - 'old_url' => 'https://cms-stern-tours.test', - 'url_v2' => 'https://v2.sterntours.test', - 'url_stern' => 'https://sterntours.test', - 'domain_tld' => 'test', - 'timezone' => 'Europe/Berlin', - 'locale' => 'de', - 'fallback_locale' => 'de', - 'key' => 'base64:cxq+xNckU1xLwp8V9Bfj9+nOK5iZL6urcZ1EBO8usXg=', - 'cipher' => 'AES-256-CBC', - 'success_key' => 'f6077389c9ce710e554763a5de02c8ec', - 'providers' => - array ( - 0 => 'Illuminate\\Auth\\AuthServiceProvider', - 1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 2 => 'Illuminate\\Bus\\BusServiceProvider', - 3 => 'Illuminate\\Cache\\CacheServiceProvider', - 4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 5 => 'Illuminate\\Cookie\\CookieServiceProvider', - 6 => 'Illuminate\\Database\\DatabaseServiceProvider', - 7 => 'Illuminate\\Encryption\\EncryptionServiceProvider', - 8 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', - 9 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', - 10 => 'Illuminate\\Hashing\\HashServiceProvider', - 11 => 'Illuminate\\Mail\\MailServiceProvider', - 12 => 'Illuminate\\Notifications\\NotificationServiceProvider', - 13 => 'Illuminate\\Pagination\\PaginationServiceProvider', - 14 => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 15 => 'Illuminate\\Queue\\QueueServiceProvider', - 16 => 'Illuminate\\Redis\\RedisServiceProvider', - 17 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 18 => 'Illuminate\\Session\\SessionServiceProvider', - 19 => 'Illuminate\\Translation\\TranslationServiceProvider', - 20 => 'Illuminate\\Validation\\ValidationServiceProvider', - 21 => 'Illuminate\\View\\ViewServiceProvider', - 22 => 'Laravel\\Tinker\\TinkerServiceProvider', - 23 => 'Laravel\\Passport\\PassportServiceProvider', - 24 => 'App\\Providers\\AppServiceProvider', - 25 => 'App\\Providers\\AuthServiceProvider', - 26 => 'App\\Providers\\EventServiceProvider', - 27 => 'App\\Providers\\RouteServiceProvider', - 28 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider', - 29 => 'Barryvdh\\DomPDF\\ServiceProvider', - 30 => 'Jenssegers\\Date\\DateServiceProvider', - 31 => 'Collective\\Html\\HtmlServiceProvider', - 32 => 'Intervention\\Image\\ImageServiceProvider', - 33 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - 34 => 'Yajra\\DataTables\\DataTablesServiceProvider', - 35 => 'Reliese\\Coders\\CodersServiceProvider', - ), - 'aliases' => - array ( - 'App' => 'Illuminate\\Support\\Facades\\App', - 'Arr' => 'Illuminate\\Support\\Arr', - 'Artisan' => 'Illuminate\\Support\\Facades\\Artisan', - 'Auth' => 'Illuminate\\Support\\Facades\\Auth', - 'Blade' => 'Illuminate\\Support\\Facades\\Blade', - 'Broadcast' => 'Illuminate\\Support\\Facades\\Broadcast', - 'Bus' => 'Illuminate\\Support\\Facades\\Bus', - 'Cache' => 'Illuminate\\Support\\Facades\\Cache', - 'Config' => 'Illuminate\\Support\\Facades\\Config', - 'Cookie' => 'Illuminate\\Support\\Facades\\Cookie', - 'Crypt' => 'Illuminate\\Support\\Facades\\Crypt', - 'DB' => 'Illuminate\\Support\\Facades\\DB', - 'Eloquent' => 'Illuminate\\Database\\Eloquent\\Model', - 'Event' => 'Illuminate\\Support\\Facades\\Event', - 'File' => 'Illuminate\\Support\\Facades\\File', - 'Gate' => 'Illuminate\\Support\\Facades\\Gate', - 'Hash' => 'Illuminate\\Support\\Facades\\Hash', - 'Lang' => 'Illuminate\\Support\\Facades\\Lang', - 'Log' => 'Illuminate\\Support\\Facades\\Log', - 'Mail' => 'Illuminate\\Support\\Facades\\Mail', - 'Notification' => 'Illuminate\\Support\\Facades\\Notification', - 'Password' => 'Illuminate\\Support\\Facades\\Password', - 'Queue' => 'Illuminate\\Support\\Facades\\Queue', - 'Redirect' => 'Illuminate\\Support\\Facades\\Redirect', - 'Redis' => 'Illuminate\\Support\\Facades\\Redis', - 'Request' => 'Illuminate\\Support\\Facades\\Request', - 'Response' => 'Illuminate\\Support\\Facades\\Response', - 'Route' => 'Illuminate\\Support\\Facades\\Route', - 'Schema' => 'Illuminate\\Support\\Facades\\Schema', - 'Session' => 'Illuminate\\Support\\Facades\\Session', - 'Storage' => 'Illuminate\\Support\\Facades\\Storage', - 'Str' => 'Illuminate\\Support\\Str', - 'URL' => 'Illuminate\\Support\\Facades\\URL', - 'Validator' => 'Illuminate\\Support\\Facades\\Validator', - 'View' => 'Illuminate\\Support\\Facades\\View', - 'Input' => 'Illuminate\\Support\\Facades\\Request', - 'Form' => 'Collective\\Html\\FormFacade', - 'Image' => 'Intervention\\Image\\Facades\\Image', - 'Carbon' => 'Carbon\\Carbon', - 'Date' => 'Jenssegers\\Date\\Date', - 'HTMLHelper' => 'App\\Helper\\HTMLHelper', - 'Util' => 'App\\Services\\Util', - 'Excel' => 'Maatwebsite\\Excel\\Facades\\Excel', - 'DataTables' => 'Yajra\\DataTables\\Facades\\DataTables', - 'PDF' => 'Barryvdh\\DomPDF\\Facade', - ), - ), - 'auth' => - array ( - 'defaults' => - array ( - 'guard' => 'web', - 'passwords' => 'users', - ), - 'guards' => - array ( - 'web' => - array ( - 'driver' => 'session', - 'provider' => 'users', - ), - 'api' => - array ( - 'driver' => 'passport', - 'provider' => 'users', - ), - ), - 'providers' => - array ( - 'users' => - array ( - 'driver' => 'eloquent', - 'model' => 'App\\User', - ), - ), - 'passwords' => - array ( - 'users' => - array ( - 'provider' => 'users', - 'table' => 'password_resets', - 'expire' => 60, - ), - ), - ), - 'booking' => - array ( - 'identifier_general' => 'booking-pdf-g-', - 'identifier_general_name' => 'booking-pdf-general-name', - 'identifier_content' => 'booking-pdf-c-', - 'identifier_content_name' => 'booking-pdf-content-name', - 'max_interval_days' => 28, - 'deposit_percentage_rate' => 25, - 'max_deposit_interval_days' => 28, - 'coupon_default_value' => '50,00', - 'coupon_valid_date_month' => 24, - ), - 'broadcasting' => - array ( - 'default' => 'log', - 'connections' => - array ( - 'pusher' => - array ( - 'driver' => 'pusher', - 'key' => '', - 'secret' => '', - 'app_id' => '', - 'options' => - array ( - 'cluster' => 'mt1', - 'encrypted' => true, - ), - ), - 'redis' => - array ( - 'driver' => 'redis', - 'connection' => 'default', - ), - 'log' => - array ( - 'driver' => 'log', - ), - 'null' => - array ( - 'driver' => 'null', - ), - ), - ), - 'cache' => - array ( - 'default' => 'file', - 'stores' => - array ( - 'apc' => - array ( - 'driver' => 'apc', - ), - 'array' => - array ( - 'driver' => 'array', - ), - 'database' => - array ( - 'driver' => 'database', - 'table' => 'cache', - 'connection' => NULL, - ), - 'file' => - array ( - 'driver' => 'file', - 'path' => '/workspace/mein.sterntours.de/storage/framework/cache/data', - ), - 'memcached' => - array ( - 'driver' => 'memcached', - 'persistent_id' => NULL, - 'sasl' => - array ( - 0 => NULL, - 1 => NULL, - ), - 'options' => - array ( - ), - 'servers' => - array ( - 0 => - array ( - 'host' => '127.0.0.1', - 'port' => 11211, - 'weight' => 100, - ), - ), - ), - 'redis' => - array ( - 'driver' => 'redis', - 'connection' => 'default', - ), - ), - 'prefix' => 'stern_tours_crm_cache', - ), - 'database' => - array ( - 'default' => 'mysql', - 'connections' => - array ( - 'sqlite' => - array ( - 'driver' => 'sqlite', - 'database' => 'stern_crm', - 'prefix' => '', - ), - 'mysql' => - array ( - 'driver' => 'mysql', - 'host' => 'global-mysql', - 'port' => '3306', - 'database' => 'stern_crm', - 'username' => 'root', - 'password' => 'password', - 'unix_socket' => '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'strict' => true, - 'engine' => NULL, - ), - 'mysql_stern' => - array ( - 'driver' => 'mysql', - 'host' => 'global-mysql', - 'port' => '3306', - 'database' => 'stern_db', - 'username' => 'root', - 'password' => 'password', - 'unix_socket' => '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'strict' => true, - 'engine' => NULL, - ), - 'pgsql' => - array ( - 'driver' => 'pgsql', - 'host' => 'global-mysql', - 'port' => '3306', - 'database' => 'stern_crm', - 'username' => 'root', - 'password' => 'password', - 'charset' => 'utf8', - 'prefix' => '', - 'schema' => 'public', - 'sslmode' => 'prefer', - ), - 'sqlsrv' => - array ( - 'driver' => 'sqlsrv', - 'host' => 'global-mysql', - 'port' => '3306', - 'database' => 'stern_crm', - 'username' => 'root', - 'password' => 'password', - 'charset' => 'utf8', - 'prefix' => '', - ), - ), - 'migrations' => 'migrations', - 'redis' => - array ( - 'client' => 'predis', - 'default' => - array ( - 'host' => 'global-redis', - 'password' => NULL, - 'port' => '6379', - 'database' => 0, - ), - ), - ), - 'datatables' => - array ( - 'search' => - array ( - 'smart' => true, - 'multi_term' => true, - 'case_insensitive' => true, - 'use_wildcards' => false, - ), - 'index_column' => 'DT_Row_Index', - 'engines' => - array ( - 'eloquent' => 'Yajra\\DataTables\\EloquentDataTable', - 'query' => 'Yajra\\DataTables\\QueryDataTable', - 'collection' => 'Yajra\\DataTables\\CollectionDataTable', - 'resource' => 'Yajra\\DataTables\\ApiResourceDataTable', - ), - 'builders' => - array ( - ), - 'nulls_last_sql' => '%s %s NULLS LAST', - 'error' => NULL, - 'columns' => - array ( - 'excess' => - array ( - 0 => 'rn', - 1 => 'row_num', - ), - 'escape' => '*', - 'raw' => - array ( - 0 => 'action', - ), - 'blacklist' => - array ( - 0 => 'password', - 1 => 'remember_token', - ), - 'whitelist' => '*', - ), - 'json' => - array ( - 'header' => - array ( - ), - 'options' => 0, - ), - 'callback' => - array ( - 0 => '$', - 1 => '$.', - 2 => 'function', - ), - ), - 'debugbar' => - array ( - 'enabled' => NULL, - 'hide_empty_tabs' => true, - 'except' => - array ( - 0 => 'telescope*', - ), - 'storage' => - array ( - 'enabled' => false, - 'driver' => 'file', - 'path' => '/workspace/mein.sterntours.de/storage/debugbar', - 'connection' => NULL, - 'provider' => '', - ), - 'editor' => 'phpstorm', - 'remote_sites_path' => NULL, - 'local_sites_path' => NULL, - 'include_vendors' => true, - 'capture_ajax' => false, - 'add_ajax_timing' => false, - 'ajax_handler_auto_show' => true, - 'ajax_handler_enable_tab' => true, - 'defer_datasets' => false, - 'error_handler' => false, - 'error_level' => 32767, - 'clockwork' => false, - 'collectors' => - array ( - 'phpinfo' => true, - 'messages' => true, - 'time' => true, - 'memory' => true, - 'exceptions' => true, - 'log' => true, - 'db' => true, - 'views' => true, - 'route' => true, - 'auth' => false, - 'gate' => true, - 'session' => true, - 'symfony_request' => true, - 'mail' => true, - 'laravel' => false, - 'events' => false, - 'default_request' => false, - 'logs' => false, - 'files' => false, - 'config' => false, - 'cache' => false, - 'models' => false, - ), - 'options' => - array ( - 'auth' => - array ( - 'show_name' => true, - ), - 'db' => - array ( - 'with_params' => true, - 'backtrace' => true, - 'timeline' => false, - 'explain' => - array ( - 'enabled' => false, - 'types' => - array ( - 0 => 'SELECT', - ), - ), - 'hints' => true, - ), - 'mail' => - array ( - 'full_log' => false, - ), - 'views' => - array ( - 'data' => false, - ), - 'route' => - array ( - 'label' => true, - ), - 'logs' => - array ( - 'file' => NULL, - ), - 'cache' => - array ( - 'values' => true, - ), - ), - 'inject' => true, - 'route_prefix' => '_debugbar', - 'route_middleware' => - array ( - ), - 'route_domain' => NULL, - 'theme' => 'auto', - 'debug_backtrace_limit' => 50, - ), - 'dompdf' => - array ( - 'show_warnings' => false, - 'public_path' => NULL, - 'convert_entities' => true, - 'options' => - array ( - 'font_dir' => '/workspace/mein.sterntours.de/storage/fonts', - 'font_cache' => '/workspace/mein.sterntours.de/storage/fonts', - 'temp_dir' => '/tmp', - 'chroot' => '/workspace/mein.sterntours.de', - 'allowed_protocols' => - array ( - 'file://' => - array ( - 'rules' => - array ( - ), - ), - 'http://' => - array ( - 'rules' => - array ( - ), - ), - 'https://' => - array ( - 'rules' => - array ( - ), - ), - ), - 'log_output_file' => NULL, - 'enable_font_subsetting' => false, - 'pdf_backend' => 'CPDF', - 'default_media_type' => 'screen', - 'default_paper_size' => 'a4', - 'default_paper_orientation' => 'portrait', - 'default_font' => 'serif', - 'dpi' => 96, - 'enable_php' => false, - 'enable_javascript' => true, - 'enable_remote' => true, - 'font_height_ratio' => 1.1, - 'enable_html5_parser' => true, - ), - 'orientation' => 'portrait', - 'defines' => - array ( - 'font_dir' => '/workspace/mein.sterntours.de/storage/fonts/', - 'font_cache' => '/workspace/mein.sterntours.de/storage/fonts/', - 'temp_dir' => '/tmp', - 'chroot' => '/workspace/mein.sterntours.de', - 'enable_font_subsetting' => false, - 'pdf_backend' => 'CPDF', - 'default_media_type' => 'print', - 'default_paper_size' => 'a4', - 'default_font' => 'sans-serif', - 'dpi' => 300, - 'enable_php' => false, - 'enable_javascript' => true, - 'enable_remote' => true, - 'font_height_ratio' => 0.8, - 'enable_html5_parser' => false, - ), - ), - 'excel' => - array ( - 'exports' => - array ( - 'chunk_size' => 1000, - 'pre_calculate_formulas' => false, - 'csv' => - array ( - 'delimiter' => ',', - 'enclosure' => '"', - 'line_ending' => ' -', - 'use_bom' => false, - 'include_separator_line' => false, - 'excel_compatibility' => false, - ), - ), - 'imports' => - array ( - 'read_only' => true, - 'heading_row' => - array ( - 'formatter' => 'slug', - ), - 'csv' => - array ( - 'delimiter' => ',', - 'enclosure' => '"', - 'escape_character' => '\\', - 'contiguous' => false, - 'input_encoding' => 'UTF-8', - ), - ), - 'extension_detector' => - array ( - 'xlsx' => 'Xlsx', - 'xlsm' => 'Xlsx', - 'xltx' => 'Xlsx', - 'xltm' => 'Xlsx', - 'xls' => 'Xls', - 'xlt' => 'Xls', - 'ods' => 'Ods', - 'ots' => 'Ods', - 'slk' => 'Slk', - 'xml' => 'Xml', - 'gnumeric' => 'Gnumeric', - 'htm' => 'Html', - 'html' => 'Html', - 'csv' => 'Csv', - 'tsv' => 'Csv', - 'pdf' => 'Dompdf', - ), - 'value_binder' => - array ( - 'default' => 'Maatwebsite\\Excel\\DefaultValueBinder', - ), - 'cache' => - array ( - 'driver' => 'memory', - 'batch' => - array ( - 'memory_limit' => 60000, - ), - 'illuminate' => - array ( - 'store' => NULL, - ), - 'default_ttl' => 10800, - ), - 'transactions' => - array ( - 'handler' => 'db', - ), - 'temporary_files' => - array ( - 'local_path' => '/tmp', - 'remote_disk' => NULL, - ), - ), - 'fewo' => - array ( - 'identifier_content' => 'fewo-pdf-general', - 'identifier_fewo' => 'fewo-pdf-', - ), - 'filesystems' => - array ( - 'default' => 'local', - 'cloud' => 's3', - 'disks' => - array ( - 'local' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app', - ), - 'public' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/public', - 'url' => 'https://mein.sterntours.test/storage', - 'visibility' => 'public', - ), - 'customer' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/customer', - 'url' => 'https://mein.sterntours.test/storage/customer', - 'visibility' => 'public', - ), - 'travel_user' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/travel_user', - 'url' => 'https://mein.sterntours.test/storage/travel_user', - 'visibility' => 'public', - ), - 'booking' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/booking', - 'url' => 'https://mein.sterntours.test/storage/booking', - 'visibility' => 'public', - ), - 'general' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/general', - 'url' => 'https://mein.sterntours.test/storage/general', - 'visibility' => 'public', - ), - 'booking_fewo' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/booking_fewo', - 'url' => 'https://mein.sterntours.test/storage/booking_fewo', - 'visibility' => 'public', - ), - 'lead' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/lead', - 'url' => 'https://mein.sterntours.test/storage/lead', - 'visibility' => 'public', - ), - 'fewo_invoices' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/fewo/invoices', - 'url' => 'https://mein.sterntours.test/storage/fewo/invoices', - 'visibility' => 'public', - ), - 'fewo_infos' => - array ( - 'driver' => 'local', - 'root' => '/workspace/mein.sterntours.de/storage/app/fewo/infos', - 'url' => 'https://mein.sterntours.test/storage/fewo/infos', - 'visibility' => 'public', - ), - 's3' => - array ( - 'driver' => 's3', - 'key' => NULL, - 'secret' => NULL, - 'region' => NULL, - 'bucket' => NULL, - 'url' => NULL, - ), - ), - ), - 'fpdf' => - array ( - 'orientation' => 'P', - 'unit' => 'mm', - 'size' => 'A4', - 'useVaporHeaders' => false, - ), - 'google2fa' => - array ( - 'enabled' => true, - 'lifetime' => 0, - 'keep_alive' => true, - 'auth' => 'auth', - 'guard' => '', - 'session_var' => 'google2fa', - 'otp_input' => 'one_time_password', - 'window' => 1, - 'forbid_old_passwords' => false, - 'otp_secret_column' => 'secret_key', - 'view' => 'auth.google2fa', - 'error_messages' => - array ( - 'wrong_otp' => 'Das \'One Time Password\' ist falsch.', - 'cannot_be_empty' => 'Das \'One Time Password\' kann nicht leer sein.', - 'unknown' => 'Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.', - ), - 'throw_exceptions' => true, - 'qrcode_image_backend' => 'svg', - ), - 'hashing' => - array ( - 'driver' => 'bcrypt', - 'bcrypt' => - array ( - 'rounds' => 10, - ), - 'argon' => - array ( - 'memory' => 1024, - 'threads' => 2, - 'time' => 2, - ), - ), - 'ide-helper' => - array ( - 'filename' => '_ide_helper', - 'models_filename' => '_ide_helper_models.php', - 'meta_filename' => '.phpstorm.meta.php', - 'include_fluent' => false, - 'include_factory_builders' => false, - 'write_model_magic_where' => true, - 'write_model_external_builder_methods' => true, - 'write_model_relation_count_properties' => true, - 'write_eloquent_model_mixins' => false, - 'include_helpers' => false, - 'helper_files' => - array ( - 0 => '/workspace/mein.sterntours.de/vendor/laravel/framework/src/Illuminate/Support/helpers.php', - ), - 'model_locations' => - array ( - 0 => 'app', - 1 => 'packages', - ), - 'ignored_models' => - array ( - ), - 'model_hooks' => - array ( - ), - 'extra' => - array ( - 'Eloquent' => - array ( - 0 => 'Illuminate\\Database\\Eloquent\\Builder', - 1 => 'Illuminate\\Database\\Query\\Builder', - ), - 'Session' => - array ( - 0 => 'Illuminate\\Session\\Store', - ), - ), - 'magic' => - array ( - 'Log' => - array ( - 'debug' => 'Monolog\\Logger::addDebug', - 'info' => 'Monolog\\Logger::addInfo', - 'notice' => 'Monolog\\Logger::addNotice', - 'warning' => 'Monolog\\Logger::addWarning', - 'error' => 'Monolog\\Logger::addError', - 'critical' => 'Monolog\\Logger::addCritical', - 'alert' => 'Monolog\\Logger::addAlert', - 'emergency' => 'Monolog\\Logger::addEmergency', - ), - ), - 'interfaces' => - array ( - ), - 'model_camel_case_properties' => false, - 'type_overrides' => - array ( - 'integer' => 'int', - 'boolean' => 'bool', - ), - 'include_class_docblocks' => false, - 'force_fqn' => false, - 'use_generics_annotations' => true, - 'additional_relation_types' => - array ( - ), - 'additional_relation_return_types' => - array ( - ), - 'post_migrate' => - array ( - ), - 'format' => 'php', - 'custom_db_types' => - array ( - ), - ), - 'image' => - array ( - 'driver' => 'gd', - ), - 'lfm' => - array ( - 'use_package_routes' => true, - 'allow_multi_user' => false, - 'allow_share_folder' => false, - 'user_folder_name' => 'IqContent\\LaravelFilemanager\\Handlers\\ConfigHandler', - 'shared_folder_name' => 'shares', - 'thumb_folder_name' => 'thumbs', - 'folder_categories' => - array ( - 'file' => - array ( - 'folder_name' => 'files', - 'startup_view' => 'grid', - 'max_size' => 50000, - 'valid_mime' => - array ( - 0 => 'image/jpeg', - 1 => 'image/pjpeg', - 2 => 'image/png', - 3 => 'image/gif', - 4 => 'image/svg+xml', - 5 => 'application/pdf', - 6 => 'text/plain', - 7 => 'application/msword', - 8 => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 9 => 'application/vnd.ms-word.template.macroEnabled.12', - 10 => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 11 => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 12 => 'application/excel', - ), - ), - 'image' => - array ( - 'folder_name' => 'photos', - 'startup_view' => 'list', - 'max_size' => 50000, - 'valid_mime' => - array ( - 0 => 'image/jpeg', - 1 => 'image/pjpeg', - 2 => 'image/png', - 3 => 'image/gif', - 4 => 'image/svg+xml', - 5 => 'application/pdf', - 6 => 'text/plain', - ), - ), - ), - 'disk' => 'public', - 'rename_file' => false, - 'alphanumeric_filename' => true, - 'alphanumeric_directory' => true, - 'should_validate_size' => false, - 'should_validate_mime' => false, - 'create_folder_mode' => 493, - 'create_file_mode' => 420, - 'should_change_file_mode' => true, - 'over_write_on_duplicate' => false, - 'should_create_thumbnails' => true, - 'raster_mimetypes' => - array ( - 0 => 'image/jpeg', - 1 => 'image/pjpeg', - 2 => 'image/png', - ), - 'thumb_img_width' => 200, - 'thumb_img_height' => 200, - 'default_color' => '#ffc926', - 'resize_aspectRatio' => false, - 'resize_containment' => true, - 'file_type_array' => - array ( - 'pdf' => 'Adobe Acrobat', - 'doc' => 'Microsoft Word', - 'docx' => 'Microsoft Word', - 'xls' => 'Microsoft Excel', - 'xlsx' => 'Microsoft Excel', - 'zip' => 'Archive', - 'gif' => 'GIF Image', - 'jpg' => 'JPEG Image', - 'jpeg' => 'JPEG Image', - 'png' => 'PNG Image', - 'ppt' => 'Microsoft PowerPoint', - 'pptx' => 'Microsoft PowerPoint', - ), - 'file_icon_array' => - array ( - 'pdf' => 'fa-file-pdf', - 'doc' => 'fa-file-word', - 'docx' => 'fa-file-word', - 'xls' => 'fa-file-excel', - 'xlsx' => 'fa-file-excel', - 'zip' => 'fa-file-archive', - 'gif' => 'fa-file-image', - 'jpg' => 'fa-file-image', - 'jpeg' => 'fa-file-image', - 'png' => 'fa-file-image', - 'ppt' => 'fa-file-powerpoint', - 'pptx' => 'fa-file-powerpoint', - 'mp3' => 'fa-file-audio', - 'mp4' => 'fa-file-video', - 'txt' => 'fa-file-alt', - 'dwg' => 'fa-file-image', - 'youtube' => 'fab fa-youtube-square', - ), - 'php_ini_overrides' => - array ( - 'memory_limit' => '256M', - ), - ), - 'localization' => - array ( - 'supportedLocales' => - array ( - 'de' => - array ( - 'name' => 'German', - 'script' => 'Latn', - 'native' => 'Deutsch', - 'regional' => 'de_DE', - ), - ), - ), - 'logging' => - array ( - 'default' => 'stack', - 'channels' => - array ( - 'stack' => - array ( - 'driver' => 'stack', - 'channels' => - array ( - 0 => 'single', - ), - ), - 'single' => - array ( - 'driver' => 'single', - 'path' => '/workspace/mein.sterntours.de/storage/logs/laravel.log', - 'level' => 'debug', - ), - 'daily' => - array ( - 'driver' => 'daily', - 'path' => '/workspace/mein.sterntours.de/storage/logs/laravel.log', - 'level' => 'debug', - 'days' => 7, - ), - 'slack' => - array ( - 'driver' => 'slack', - 'url' => NULL, - 'username' => 'Laravel Log', - 'emoji' => ':boom:', - 'level' => 'critical', - ), - 'stderr' => - array ( - 'driver' => 'monolog', - 'handler' => 'Monolog\\Handler\\StreamHandler', - 'with' => - array ( - 'stream' => 'php://stderr', - ), - ), - 'syslog' => - array ( - 'driver' => 'syslog', - 'level' => 'debug', - ), - 'errorlog' => - array ( - 'driver' => 'errorlog', - 'level' => 'debug', - ), - 'browser' => - array ( - 'driver' => 'single', - 'path' => '/workspace/mein.sterntours.de/storage/logs/browser.log', - 'level' => 'debug', - 'days' => 14, - ), - ), - ), - 'mail' => - array ( - 'driver' => 'smtp', - 'host' => 'global-mailpit', - 'port' => '587', - 'from' => - array ( - 'address' => 'stern@sterntours.de', - 'name' => 'DEV Reisebüro STERN TOURS', - ), - 'mail_bbc' => - array ( - 0 => 'kevin@adametz.media', - ), - 'mail_fewo_employee' => 'kevin@adametz.media', - 'encryption' => 'TLS', - 'username' => 'stern@stern-tours.de', - 'password' => '13C!NlecB!Phil4beAxKl', - 'sendmail' => '/usr/sbin/sendmail -bs', - 'markdown' => - array ( - 'theme' => 'default', - 'paths' => - array ( - 0 => '/workspace/mein.sterntours.de/resources/views/vendor/mail', - ), - ), - ), - 'models' => - array ( - '*' => - array ( - 'path' => '/workspace/mein.sterntours.de/app/Models', - 'namespace' => 'App\\Models', - 'parent' => 'Illuminate\\Database\\Eloquent\\Model', - 'use' => - array ( - ), - 'connection' => false, - 'timestamps' => true, - 'soft_deletes' => true, - 'date_format' => 'Y-m-d H:i:s', - 'per_page' => 15, - 'base_files' => false, - 'snake_attributes' => true, - 'qualified_tables' => false, - 'hidden' => - array ( - 0 => '*secret*', - 1 => '*password', - 2 => '*token', - ), - 'guarded' => - array ( - ), - 'casts' => - array ( - '*_json' => 'json', - ), - 'except' => - array ( - 0 => 'migrations', - ), - ), - ), - 'permissions' => - array ( - 'groups' => - array ( - 0 => - array ( - 'my-dat' => - array ( - 'name' => 'Ihre Daten', - 'color' => 'client', - ), - ), - 1 => - array ( - 'crm' => - array ( - 'name' => 'ADMIN CRM ', - 'color' => 'admin', - ), - 'crm-tp' => - array ( - 'name' => 'ADMIN CRM > Reiseprogramme', - 'color' => 'admin', - ), - 'crm-tp-pr' => - array ( - 'name' => 'ADMIN CRM > Reiseprogramme > Programme', - 'color' => 'admin', - ), - 'crm-tp-dr' => - array ( - 'name' => 'ADMIN CRM > Reiseprogramme > Vorlagen', - 'color' => 'admin', - ), - 'crm-tp-tc' => - array ( - 'name' => 'ADMIN CRM > Reiseprogramme > Inhalte', - 'color' => 'admin', - ), - 'crm-bo' => - array ( - 'name' => 'ADMIN CRM > Buchungen', - 'color' => 'admin', - ), - 'crm-bo-re' => - array ( - 'name' => 'ADMIN CRM > Buchungen > Übersicht', - 'color' => 'admin', - ), - 'crm-bo-bo' => - array ( - 'name' => 'ADMIN CRM > Buchungen > Buchungen', - 'color' => 'admin', - ), - 'crm-bo-le' => - array ( - 'name' => 'ADMIN CRM > Buchungen > Anfragen', - 'color' => 'admin', - ), - 'crm-bo-cu' => - array ( - 'name' => 'ADMIN CRM > Buchungen > Kunden', - 'color' => 'admin', - ), - 'crm-cm' => - array ( - 'name' => 'ADMIN CRM > Kundenverwaltung', - 'color' => 'admin', - ), - 'crm-cm-cf' => - array ( - 'name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)', - 'color' => 'admin', - ), - 'crm-cm-bf' => - array ( - 'name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)', - 'color' => 'admin', - ), - 'crm-mail' => - array ( - 'name' => 'ADMIN CRM > E-Mails', - 'color' => 'admin', - ), - 'crm-mail-le' => - array ( - 'name' => 'ADMIN CRM > E-Mails > Anfragen', - 'color' => 'admin', - ), - 'crm-mail-bo' => - array ( - 'name' => 'ADMIN CRM > E-Mails > Buchungen', - 'color' => 'admin', - ), - 'crm-mail-bf' => - array ( - 'name' => 'ADMIN CRM > E-Mails > Buchungen (Fewo)', - 'color' => 'admin', - ), - 'crm-iq-tl' => - array ( - 'name' => 'ADMIN CRM > Reisebausteine', - 'color' => 'admin', - ), - 'crm-iq-tl-pro' => - array ( - 'name' => 'ADMIN CRM > Reisebausteine > Programm', - 'color' => 'admin', - ), - 'crm-iq-tl-gp' => - array ( - 'name' => 'ADMIN CRM > Reisebausteine > Gruppe', - 'color' => 'admin', - ), - 'crm-iq-tl-it' => - array ( - 'name' => 'ADMIN CRM > Reisebausteine > Baustein', - 'color' => 'admin', - ), - 'crm-old-cm' => - array ( - 'name' => 'ADMIN CRM altes System > Kundenverwaltung', - 'color' => 'info', - ), - 'cms' => - array ( - 'name' => 'ADMIN CMS', - 'color' => 'secondary', - ), - 'cms-iq-assets' => - array ( - 'name' => 'ADMIN CMS > Medien', - 'color' => 'secondary', - ), - 'cms-tg' => - array ( - 'name' => 'ADMIN CMS > Reiseführer', - 'color' => 'secondary', - ), - 'cms-fewo' => - array ( - 'name' => 'ADMIN CMS > FeWo', - 'color' => 'secondary', - ), - 'cms-book' => - array ( - 'name' => 'ADMIN CMS > Buchungen', - 'color' => 'secondary', - ), - 'cms-fb' => - array ( - 'name' => 'ADMIN CMS > Feedback', - 'color' => 'secondary', - ), - 'cms-nw' => - array ( - 'name' => 'ADMIN CMS > News', - 'color' => 'secondary', - ), - 'cms-aq' => - array ( - 'name' => 'ADMIN CMS > Fragen & Antworten', - 'color' => 'secondary', - ), - 'cms-sb' => - array ( - 'name' => 'ADMIN CMS > Sidebar', - 'color' => 'secondary', - ), - 'cms-cn' => - array ( - 'name' => 'ADMIN CMS > Inhalte', - 'color' => 'secondary', - ), - 'cms-cn-in' => - array ( - 'name' => 'ADMIN CMS > Inhalte > Infos', - 'color' => 'secondary', - ), - 'cms-cn-al' => - array ( - 'name' => 'ADMIN CMS > Inhalte > Inhalte', - 'color' => 'secondary', - ), - 'cms-cn-au' => - array ( - 'name' => 'ADMIN CMS > Inhalte > Autor', - 'color' => 'secondary', - ), - 'cms-cn-co' => - array ( - 'name' => 'ADMIN CMS > Inhalte > Länder', - 'color' => 'secondary', - ), - 'cms-newsletter' => - array ( - 'name' => 'ADMIN CMS > Newsletter', - 'color' => 'secondary', - ), - 'crm-nav-api' => - array ( - 'name' => 'ADMIN CRM > Navigation API', - 'color' => 'secondary', - ), - ), - 2 => - array ( - 'sua-bo-n-edit' => - array ( - 'name' => 'SUPERADMIN > Buchungen > Notizen > bearbeiten', - 'color' => 'secondary', - ), - 'sua-fewo-n-edit' => - array ( - 'name' => 'SUPERADMIN > FeWo > Notizen > bearbeiten', - 'color' => 'secondary', - ), - 'sua-st' => - array ( - 'name' => 'SUPERADMIN > Einstellungen', - 'color' => 'superadmin', - ), - 'sua-st-al' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Airline', - 'color' => 'superadmin', - ), - 'sua-st-ap' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Airport', - 'color' => 'superadmin', - ), - 'sua-st-em' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > E-Mails', - 'color' => 'superadmin', - ), - 'sua-st-ke' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Keywords', - 'color' => 'superadmin', - ), - 'sua-st-sp' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Leistungsträger', - 'color' => 'superadmin', - ), - 'sua-st-tn' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Nationalitäten', - 'color' => 'superadmin', - ), - 'sua-st-co' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reiseländer', - 'color' => 'superadmin', - ), - 'sua-st-tp' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reiseprogramme', - 'color' => 'superadmin', - ), - 'sua-st-tpl' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reiseorte', - 'color' => 'superadmin', - ), - 'sua-st-bs' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reisestatus', - 'color' => 'superadmin', - ), - 'sua-st-tc' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Veranstalter', - 'color' => 'superadmin', - ), - 'sua-st-tca' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reiseart', - 'color' => 'superadmin', - ), - 'sua-st-tap' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Zielflughafen', - 'color' => 'superadmin', - ), - 'sua-st-in' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Versicherungen', - 'color' => 'superadmin', - ), - 'sua-st-ca' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Kategorien', - 'color' => 'superadmin', - ), - 'sua-st-tgn' => - array ( - 'name' => 'SUPERADMIN > Einstellungen > Reisehinweise', - 'color' => 'superadmin', - ), - 'sua-re' => - array ( - 'name' => 'SUPERADMIN > Export', - 'color' => 'superadmin', - ), - 'sua-re-bo' => - array ( - 'name' => 'SUPERADMIN > Export > Buchungen', - 'color' => 'superadmin', - ), - 'sua-re-pp' => - array ( - 'name' => 'SUPERADMIN > Export > Leistungsträger', - 'color' => 'superadmin', - ), - 'sua-re-fw' => - array ( - 'name' => 'SUPERADMIN > Export > Fewo', - 'color' => 'superadmin', - ), - 'sua-re-le' => - array ( - 'name' => 'SUPERADMIN > Export > Anfragen', - 'color' => 'superadmin', - ), - 'sua-ur-rt' => - array ( - 'name' => 'SUPERADMIN > User Rechte', - 'color' => 'danger', - ), - ), - ), - 'roles' => - array ( - 0 => 'Kunde', - 1 => 'Admin', - 2 => 'SuperAdmin', - ), - ), - 'queue' => - array ( - 'default' => 'sync', - 'connections' => - array ( - 'sync' => - array ( - 'driver' => 'sync', - ), - 'database' => - array ( - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, - ), - 'beanstalkd' => - array ( - 'driver' => 'beanstalkd', - 'host' => 'localhost', - 'queue' => 'default', - 'retry_after' => 90, - ), - 'sqs' => - array ( - 'driver' => 'sqs', - 'key' => 'your-public-key', - 'secret' => 'your-secret-key', - 'prefix' => 'https://sqs.us-east-1.amazonaws.com/your-account-id', - 'queue' => 'your-queue-name', - 'region' => 'us-east-1', - ), - 'redis' => - array ( - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => 'default', - 'retry_after' => 90, - 'block_for' => NULL, - ), - ), - 'failed' => - array ( - 'database' => 'mysql', - 'table' => 'failed_jobs', - ), - ), - 'services' => - array ( - 'mailgun' => - array ( - 'domain' => NULL, - 'secret' => NULL, - ), - 'ses' => - array ( - 'key' => NULL, - 'secret' => NULL, - 'region' => 'us-east-1', - ), - 'sparkpost' => - array ( - 'secret' => NULL, - ), - 'stripe' => - array ( - 'model' => 'App\\User', - 'key' => NULL, - 'secret' => NULL, - ), - ), - 'session' => - array ( - 'driver' => 'file', - 'lifetime' => '120', - 'expire_on_close' => false, - 'encrypt' => false, - 'files' => '/workspace/mein.sterntours.de/storage/framework/sessions', - 'connection' => NULL, - 'table' => 'sessions', - 'store' => NULL, - 'lottery' => - array ( - 0 => 2, - 1 => 100, - ), - 'cookie' => 'stern_tours_crm_session', - 'path' => '/', - 'domain' => NULL, - 'secure' => false, - 'http_only' => true, - 'same_site' => NULL, - ), - 'tinker' => - array ( - 'commands' => - array ( - ), - 'alias' => - array ( - ), - 'dont_alias' => - array ( - ), - 'trust_project' => 'always', - ), - 'trustedproxy' => - array ( - 'proxies' => NULL, - 'headers' => 62, - ), - 'view' => - array ( - 'paths' => - array ( - 0 => '/workspace/mein.sterntours.de/resources/views', - ), - 'compiled' => '/workspace/mein.sterntours.de/storage/framework/views', - ), - 'cart' => - array ( - 'tax' => 10, - 'database' => - array ( - 'connection' => NULL, - 'table' => 'shoppingcart', - ), - 'destroy_on_logout' => false, - 'format' => - array ( - 'decimals' => 2, - 'decimal_point' => '.', - 'thousand_seperator' => '', - ), - 'discountOnFees' => false, - ), - 'boost' => - array ( - 'enabled' => true, - 'browser_logs_watcher' => true, - 'executable_paths' => - array ( - 'php' => NULL, - 'composer' => NULL, - 'npm' => NULL, - 'vendor_bin' => NULL, - ), - ), - 'mcp' => - array ( - 'redirect_domains' => - array ( - 0 => '*', - ), - ), - 'passport' => - array ( - 'guard' => 'web', - 'private_key' => NULL, - 'public_key' => NULL, - 'client_uuids' => false, - 'personal_access_client' => - array ( - 'id' => NULL, - 'secret' => NULL, - ), - ), - 'flare' => - array ( - 'key' => NULL, - 'flare_middleware' => - array ( - 0 => 'Spatie\\FlareClient\\FlareMiddleware\\RemoveRequestIp', - 1 => 'Spatie\\FlareClient\\FlareMiddleware\\AddGitInformation', - 2 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddNotifierName', - 3 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddEnvironmentInformation', - 4 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddExceptionInformation', - 5 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddDumps', - 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddLogs' => - array ( - 'maximum_number_of_collected_logs' => 200, - ), - 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddQueries' => - array ( - 'maximum_number_of_collected_queries' => 200, - 'report_query_bindings' => true, - ), - 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddJobs' => - array ( - 'max_chained_job_reporting_depth' => 5, - ), - 6 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddContext', - 7 => 'Spatie\\LaravelIgnition\\FlareMiddleware\\AddExceptionHandledStatus', - 'Spatie\\FlareClient\\FlareMiddleware\\CensorRequestBodyFields' => - array ( - 'censor_fields' => - array ( - 0 => 'password', - 1 => 'password_confirmation', - ), - ), - 'Spatie\\FlareClient\\FlareMiddleware\\CensorRequestHeaders' => - array ( - 'headers' => - array ( - 0 => 'API-KEY', - 1 => 'Authorization', - 2 => 'Cookie', - 3 => 'Set-Cookie', - 4 => 'X-CSRF-TOKEN', - 5 => 'X-XSRF-TOKEN', - ), - ), - ), - 'send_logs_as_events' => true, - ), - 'ignition' => - array ( - 'editor' => 'phpstorm', - 'theme' => 'auto', - 'enable_share_button' => true, - 'register_commands' => false, - 'solution_providers' => - array ( - 0 => 'Spatie\\Ignition\\Solutions\\SolutionProviders\\BadMethodCallSolutionProvider', - 1 => 'Spatie\\Ignition\\Solutions\\SolutionProviders\\MergeConflictSolutionProvider', - 2 => 'Spatie\\Ignition\\Solutions\\SolutionProviders\\UndefinedPropertySolutionProvider', - 3 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\IncorrectValetDbCredentialsSolutionProvider', - 4 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingAppKeySolutionProvider', - 5 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\DefaultDbNameSolutionProvider', - 6 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\TableNotFoundSolutionProvider', - 7 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingImportSolutionProvider', - 8 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\InvalidRouteActionSolutionProvider', - 9 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\ViewNotFoundSolutionProvider', - 10 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\RunningLaravelDuskInProductionProvider', - 11 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingColumnSolutionProvider', - 12 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\UnknownValidationSolutionProvider', - 13 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingMixManifestSolutionProvider', - 14 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingViteManifestSolutionProvider', - 15 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\MissingLivewireComponentSolutionProvider', - 16 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\UndefinedViewVariableSolutionProvider', - 17 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\GenericLaravelExceptionSolutionProvider', - 18 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\OpenAiSolutionProvider', - 19 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\SailNetworkSolutionProvider', - 20 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\UnknownMysql8CollationSolutionProvider', - 21 => 'Spatie\\LaravelIgnition\\Solutions\\SolutionProviders\\UnknownMariadbCollationSolutionProvider', - ), - 'ignored_solution_providers' => - array ( - ), - 'enable_runnable_solutions' => NULL, - 'remote_sites_path' => '/workspace/mein.sterntours.de', - 'local_sites_path' => '', - 'housekeeping_endpoint_prefix' => '_ignition', - 'settings_file_path' => '', - 'recorders' => - array ( - 0 => 'Spatie\\LaravelIgnition\\Recorders\\DumpRecorder\\DumpRecorder', - 1 => 'Spatie\\LaravelIgnition\\Recorders\\JobRecorder\\JobRecorder', - 2 => 'Spatie\\LaravelIgnition\\Recorders\\LogRecorder\\LogRecorder', - 3 => 'Spatie\\LaravelIgnition\\Recorders\\QueryRecorder\\QueryRecorder', - ), - 'open_ai_key' => NULL, - 'with_stack_frame_arguments' => true, - 'argument_reducers' => - array ( - 0 => 'Spatie\\Backtrace\\Arguments\\Reducers\\BaseTypeArgumentReducer', - 1 => 'Spatie\\Backtrace\\Arguments\\Reducers\\ArrayArgumentReducer', - 2 => 'Spatie\\Backtrace\\Arguments\\Reducers\\StdClassArgumentReducer', - 3 => 'Spatie\\Backtrace\\Arguments\\Reducers\\EnumArgumentReducer', - 4 => 'Spatie\\Backtrace\\Arguments\\Reducers\\ClosureArgumentReducer', - 5 => 'Spatie\\Backtrace\\Arguments\\Reducers\\DateTimeArgumentReducer', - 6 => 'Spatie\\Backtrace\\Arguments\\Reducers\\DateTimeZoneArgumentReducer', - 7 => 'Spatie\\Backtrace\\Arguments\\Reducers\\SymphonyRequestArgumentReducer', - 8 => 'Spatie\\LaravelIgnition\\ArgumentReducers\\ModelArgumentReducer', - 9 => 'Spatie\\LaravelIgnition\\ArgumentReducers\\CollectionArgumentReducer', - 10 => 'Spatie\\Backtrace\\Arguments\\Reducers\\StringableArgumentReducer', - ), - ), - 'sluggable' => - array ( - 'source' => NULL, - 'maxLength' => NULL, - 'maxLengthKeepWords' => true, - 'method' => NULL, - 'separator' => '-', - 'unique' => true, - 'uniqueSuffix' => NULL, - 'firstUniqueSuffix' => 2, - 'includeTrashed' => false, - 'reserved' => NULL, - 'onUpdate' => false, - 'slugEngineOptions' => - array ( - ), - ), -); diff --git a/config/app.php b/config/app.php index ab7a418..4cf57f2 100755 --- a/config/app.php +++ b/config/app.php @@ -40,6 +40,7 @@ return [ */ 'debug' => env('APP_DEBUG', false), + 'exception_mail' => env('EXCEPTION_MAIL', 'exception@adametz.media'), /* |-------------------------------------------------------------------------- diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php new file mode 100644 index 0000000..17cd0b4 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php new file mode 100644 index 0000000..70a1c2f --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300001_phase3_create_participants_unified_table.php @@ -0,0 +1,123 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300002_phase3_drop_old_participant_tables.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300002_phase3_drop_old_participant_tables.php new file mode 100644 index 0000000..3f2cbec --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_300002_phase3_drop_old_participant_tables.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400002_phase4_create_notices_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400002_phase4_create_notices_table.php new file mode 100644 index 0000000..624c891 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400002_phase4_create_notices_table.php @@ -0,0 +1,94 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400003_phase4_create_attachments_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400003_phase4_create_attachments_table.php new file mode 100644 index 0000000..7f44feb --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400003_phase4_create_attachments_table.php @@ -0,0 +1,118 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400004_phase4_drop_old_communication_tables.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400004_phase4_drop_old_communication_tables.php new file mode 100644 index 0000000..bbdff3f --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400004_phase4_drop_old_communication_tables.php @@ -0,0 +1,45 @@ +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.' + ); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400005_phase4_drop_old_notice_tables.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400005_phase4_drop_old_notice_tables.php new file mode 100644 index 0000000..d50006d --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2025_04_15_400005_phase4_drop_old_notice_tables.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100002_create_offer_versions_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100002_create_offer_versions_table.php new file mode 100644 index 0000000..2cc6e30 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100002_create_offer_versions_table.php @@ -0,0 +1,81 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100003_create_offer_items_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100003_create_offer_items_table.php new file mode 100644 index 0000000..c540a9b --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100003_create_offer_items_table.php @@ -0,0 +1,68 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100004_create_offer_templates_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100004_create_offer_templates_table.php new file mode 100644 index 0000000..67d4bab --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100004_create_offer_templates_table.php @@ -0,0 +1,70 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100005_create_offer_files_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100005_create_offer_files_table.php new file mode 100644 index 0000000..2e8442f --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100005_create_offer_files_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100006_create_offer_access_tokens_table.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100006_create_offer_access_tokens_table.php new file mode 100644 index 0000000..cd10b9c --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100006_create_offer_access_tokens_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php new file mode 100644 index 0000000..32681ab --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/migrations/2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php @@ -0,0 +1,57 @@ +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']); + }); + } +}; diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php new file mode 100644 index 0000000..02b503c --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php @@ -0,0 +1,132 @@ + 'int', + 'inquiry_id' => 'int', + 'booking_id' => 'int', + 'current_version_id' => 'int', + 'created_by' => 'int', + ]; + + public function contact(): BelongsTo + { + 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); + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferAccessToken.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferAccessToken.php new file mode 100644 index 0000000..54fb1c3 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferAccessToken.php @@ -0,0 +1,120 @@ + '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; + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferFile.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferFile.php new file mode 100644 index 0000000..be9332c --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferFile.php @@ -0,0 +1,99 @@ + '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)]; + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferItem.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferItem.php new file mode 100644 index 0000000..9baf8f8 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferItem.php @@ -0,0 +1,86 @@ + '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); + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferTemplate.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferTemplate.php new file mode 100644 index 0000000..2f49ec6 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferTemplate.php @@ -0,0 +1,77 @@ + '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); + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferVersion.php b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferVersion.php new file mode 100644 index 0000000..d85b1a5 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/FILES/models/OfferVersion.php @@ -0,0 +1,126 @@ + '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; + } +} diff --git a/dev/backups/phase2-offers-2026-04-17/MANIFEST.md b/dev/backups/phase2-offers-2026-04-17/MANIFEST.md new file mode 100644 index 0000000..93962ee --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/MANIFEST.md @@ -0,0 +1,158 @@ +# Backup: Phase-2 + Offers (Rückbau für Phase-1-Live-Deploy) + +**Erstellt:** 2026-04-17 +**Zweck:** Der Workspace enthielt gemischt Phase 1 + Phase 2 (Code-Umstellung `inquiry_id`, `$table='contacts'/'inquiries'`) + Offers-Modul. Phase 1 muss zuerst auf Live, dann erst Phase 2 + Offers. Dieses Backup sichert alle **Phase-2- und Offers-Artefakte**, damit sie nach erfolgreichem Phase-1-Live-Deploy per `restore.sh` wieder in den Workspace eingespielt werden können. + +Parallel dazu existiert ein **Tarball-Backup** des gesamten Workspace-Zustands vor dem Rückbau unter `../../../../../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz` (182 MB) und ein **Git-Commit** `e3dc1af` (lokal auf `master`, nicht gepusht) als doppeltes Sicherheitsnetz. + +--- + +## Was im Backup liegt + +### `FILES/migrations/` (18 Migrations-Dateien) + +**Phase 2 (3 Dateien):** +- `2025_04_15_200001_phase2_rename_customer_to_contacts.php` — RENAME TABLE customer → contacts +- `2025_04_15_200002_phase2_rename_lead_to_inquiries.php` — RENAME TABLE lead → inquiries +- `2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php` — Spalte `booking.lead_id` → `booking.inquiry_id` + +**Phase 3 (2 Dateien):** +- `2025_04_15_300001_phase3_create_participants_unified_table.php` +- `2025_04_15_300002_phase3_drop_old_participant_tables.php` + +**Phase 4 (6 Dateien):** +- `2025_04_15_400001_phase4_create_communications_table.php` +- `2025_04_15_400002_phase4_create_notices_table.php` +- `2025_04_15_400003_phase4_create_attachments_table.php` +- `2025_04_15_400004_phase4_drop_old_communication_tables.php` +- `2025_04_15_400005_phase4_drop_old_notice_tables.php` +- `2025_04_15_400006_phase4_drop_old_attachment_tables.php` + +**Offers (7 Dateien):** +- `2026_04_17_100001_create_offers_table.php` +- `2026_04_17_100002_create_offer_versions_table.php` +- `2026_04_17_100003_create_offer_items_table.php` +- `2026_04_17_100004_create_offer_templates_table.php` +- `2026_04_17_100005_create_offer_files_table.php` +- `2026_04_17_100006_create_offer_access_tokens_table.php` +- `2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php` + +### `FILES/models/` (6 Offer-Models) + +- `Offer.php` — **neues** Offer-Model des Offers-Moduls (überschreibt das Legacy-Reliese-Model in `app/Models/Offer.php`; Legacy-Version ist in `HEAD^` bzw. Commit `389d5d1`) +- `OfferVersion.php` +- `OfferItem.php` +- `OfferTemplate.php` +- `OfferFile.php` +- `OfferAccessToken.php` + +### `PATCHES/` (Diffs für Phase-2/Offers-Rückbau) + +Jede Datei enthält den **vollständigen** oder **Phase-1-only**-Diff gegen `HEAD^` (= `389d5d1`, Basis-Live-Stand): + +- `Booking.php.full.diff` — kompletter Vorher-Diff (inkl. Phase-2 + Offers-Änderungen, die zurückgebaut wurden) +- `Booking.php.phase1-only.diff` — was nach Rückbau übrig blieb: **nur** `HasFactory` + `$dates`→`$casts datetime` (Laravel-10-Upgrade) +- `Customer.php.phase1-only.diff` — Phase-1-Arbeit (SoftDeletes, Merge-Fields, Relations, Global Scope); `$table = 'contacts'` zurück auf `'customer'` +- `Lead.php.phase1-only.diff` — `$table = 'inquiries'` zurück auf `'lead'` (+ Phase-1-$casts-Refactoring) +- `Contact.php.phase1-only.diff` — komplett neue Datei (Contacts-Modul); `$table = 'contacts'` zurück auf `'customer'` +- `filesystems.php.full.diff` — entfernte `offer`-Disk (komplett zurückgerollt; Datei jetzt identisch mit HEAD^) +- `Repositories.full.diff` / `Repositories.phase1-only.diff` — BookingPDFRepository (Laravel-10-Upgrade `Storage::disk()->path()`), LeadRepository, CustomerMailRepository +- `Controllers.phase1-only.diff` — RequestController (Phase-1-$casts), API/BookingController, Admin/Report*, LeadController, CustomerController, ContactController (komplett neu) +- `Services-Commands.phase1-only.diff` — BookingImport, SyncNewsletterKulturreisen (neu), Contacts*Duplicates (neu) +- `Views.phase1-only.diff` — (leer, reine Phase-2-Views wurden zurückgerollt) + +--- + +## Was im Workspace VERBLEIBT (Phase 1 + Laravel 10) + +**Deploy-ready auf Live** — alle Phase-1-Artefakte, die auf Test bereits laufen: + +### Neue Dateien (Phase 1) +- `database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php` +- `database/migrations/2025_04_15_100002_phase1_add_soft_delete_to_customer_table.php` +- `app/Models/Contact.php` (mit `$table = 'customer'`) +- `app/Repositories/ContactRepository.php` +- `app/Http/Controllers/ContactController.php` +- `app/Console/Commands/ContactsFindDuplicates.php` +- `app/Console/Commands/ContactsMergeDuplicates.php` +- `resources/views/contact/*.blade.php` (5 Dateien) + +### Modifizierte Dateien (Phase 1 + Laravel 10 Upgrade) +- `app/Models/Customer.php` — SoftDeletes, Merge-Fields, Global Scope, Relations +- `app/Models/Booking.php` — HasFactory, `$dates`→`$casts datetime` +- `app/Models/Lead.php` — Phase-1-$casts-Refactoring +- Viele weitere Models mit Laravel-10-Upgrade-Änderungen +- `app/Repositories/BookingPDFRepository.php` — `Storage::disk()->path()` statt deprecated `getAdapter()->getPathPrefix()` +- `routes/web.php` — `/contacts`, `/contact/*` Routen +- `resources/views/layouts/includes/layout-sidenav.blade.php` — Contacts-Menüpunkt +- Weitere modifizierte Dateien, u.a. diverse Views, Tests (`tests/Feature/*`, `tests/Unit/*`), Konfiguration (`phpunit.xml`, `config/trustedproxy.php`), Composer/Package-Dateien +- `app/Services/MailDirService.php` (neu) +- `database/factories/BookingFactory.php`, `CustomerFactory.php`, `LeadFactory.php` (neu) + +**Alle diese Dateien gehen mit dem Phase-1-Live-Deploy mit.** + +--- + +## Restore-Anleitung + +### Automatisch (empfohlen) + +```bash +bash dev/backups/phase2-offers-2026-04-17/restore.sh +``` + +Das Script: +1. Prüft, ob Phase 2 auf Live eingespielt wurde (erfordert bewusste Bestätigung) +2. Spielt die 18 Migrations-Dateien zurück nach `database/migrations/` +3. Spielt die 6 Offer-Models zurück nach `app/Models/` (inkl. Überschreiben der Legacy-`Offer.php`) +4. Wendet die Phase-2/Offers-Änderungen wieder an (Booking.php inquiry_id + offer()-Relation, Customer/Lead/Contact $table, Repositories, Controllers, Services, Commands, Views, filesystems.php) +5. Zeigt einen abschließenden `git status` zur Verifikation + +### Manuell + +Falls das Script fehlschlägt oder man einzelne Teile prüfen möchte: + +**Migrations zurück:** +```bash +cp dev/backups/phase2-offers-2026-04-17/FILES/migrations/*.php database/migrations/ +``` + +**Models zurück (WARNUNG: überschreibt Legacy-Offer.php):** +```bash +cp dev/backups/phase2-offers-2026-04-17/FILES/models/*.php app/Models/ +``` + +**Code-Änderungen aus dem WIP-Sicherheits-Commit (`e3dc1af`) zurückholen:** +```bash +git checkout e3dc1af -- app/Models/Booking.php app/Models/Customer.php app/Models/Contact.php app/Models/Lead.php +git checkout e3dc1af -- app/Repositories/BookingPDFRepository.php app/Repositories/LeadRepository.php app/Repositories/CustomerMailRepository.php +git checkout e3dc1af -- app/Http/Controllers/RequestController.php app/Http/Controllers/API/BookingController.php +git checkout e3dc1af -- app/Http/Controllers/Admin/ReportController.php app/Http/Controllers/Admin/ReportProviderController.php app/Http/Controllers/Admin/ReportLeadsController.php +git checkout e3dc1af -- app/Http/Controllers/LeadController.php app/Http/Controllers/CustomerController.php app/Http/Controllers/ContactController.php +git checkout e3dc1af -- app/Services/BookingImport.php +git checkout e3dc1af -- app/Console/Commands/SyncNewsletterKulturreisen.php app/Console/Commands/ContactsFindDuplicates.php app/Console/Commands/ContactsMergeDuplicates.php +git checkout e3dc1af -- resources/views/customer/mail/modal-show-mail-inner.blade.php resources/views/pdf/components/booking_head.blade.php resources/views/pdf/components/booking_header.blade.php +git checkout e3dc1af -- config/filesystems.php +``` + +**Alternative — kompletter Restore aus dem Git-Commit:** +```bash +git checkout e3dc1af -- . +``` +(Aber dann sind auch die Phase-2-Migrationen in `database/migrations/` wieder da, was richtig ist.) + +--- + +## Notfall-Rollback (alles rückgängig, auch Phase-1-Rückbau) + +Falls der Phase-1-Rückbau komplett falsch war und man zum Ausgangszustand zurück will: + +```bash +# Option A: Git +git reset --hard e3dc1af # setzt Workspace auf den WIP-Sicherheits-Commit + +# Option B: Tarball (falls Git nicht funktioniert) +cd /workspace/mein.sterntours.de +rm -rf * .[a-z]* # Vorsicht! Löscht alles außer dem übergeordneten Dir +tar -xzf ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz +``` diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.full.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.full.diff new file mode 100644 index 0000000..2a28d0a --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.full.diff @@ -0,0 +1,110 @@ +diff --git a/app/Models/Booking.php b/app/Models/Booking.php +index 79a91ba..f3bf11c 100644 +--- a/app/Models/Booking.php ++++ b/app/Models/Booking.php +@@ -9,6 +9,7 @@ namespace App\Models; + use Carbon\Carbon; + use App\Services\Util; + use App\Services\Passolution; ++use Illuminate\Database\Eloquent\Factories\HasFactory; + use Illuminate\Database\Eloquent\Model; + use Illuminate\Database\Eloquent\Collection; + +@@ -18,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 +@@ -203,13 +204,16 @@ use Illuminate\Database\Eloquent\Collection; + */ + class Booking extends Model + { ++ use HasFactory; ++ + protected $connection = 'mysql'; + + protected $table = 'booking'; + + 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', +@@ -237,25 +241,24 @@ class Booking extends Model + 'is_rail_fly' => 'bool', + 'comfort' => 'bool', + 'airline_ids' => 'array', +- 'participant_pass' => 'bool' +- ]; +- +- protected $dates = [ +- 'booking_date', +- 'start_date', +- 'end_date', +- 'participant_birthdate', +- 'final_payment_date', +- 'refund_date', +- 'lawyer_date', +- 'xx_tkt_date' +- +- ]; ++ 'participant_pass' => 'bool', ++ 'booking_date' => 'datetime', ++ 'start_date' => 'datetime', ++ 'end_date' => 'datetime', ++ 'participant_birthdate' => 'datetime', ++ 'final_payment_date' => 'datetime', ++ 'refund_date' => 'datetime', ++ 'lawyer_date' => 'datetime', ++ 'xx_tkt_date' => 'datetime', ++ ]; ++ ++ + + protected $fillable = [ + 'booking_date', + 'customer_id', +- 'lead_id', ++ 'inquiry_id', ++ 'offer_id', + 'new_drafts', + 'sf_guard_user_id', + 'branch_id', +@@ -392,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() diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.phase1-only.diff new file mode 100644 index 0000000..90acf25 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Booking.php.phase1-only.diff @@ -0,0 +1,54 @@ +diff --git a/app/Models/Booking.php b/app/Models/Booking.php +index 79a91ba..3d61b89 100644 +--- a/app/Models/Booking.php ++++ b/app/Models/Booking.php +@@ -9,6 +9,7 @@ namespace App\Models; + use Carbon\Carbon; + use App\Services\Util; + use App\Services\Passolution; ++use Illuminate\Database\Eloquent\Factories\HasFactory; + use Illuminate\Database\Eloquent\Model; + use Illuminate\Database\Eloquent\Collection; + +@@ -203,6 +204,8 @@ use Illuminate\Database\Eloquent\Collection; + */ + class Booking extends Model + { ++ use HasFactory; ++ + protected $connection = 'mysql'; + + protected $table = 'booking'; +@@ -237,20 +240,18 @@ class Booking extends Model + 'is_rail_fly' => 'bool', + 'comfort' => 'bool', + 'airline_ids' => 'array', +- 'participant_pass' => 'bool' +- ]; +- +- protected $dates = [ +- 'booking_date', +- 'start_date', +- 'end_date', +- 'participant_birthdate', +- 'final_payment_date', +- 'refund_date', +- 'lawyer_date', +- 'xx_tkt_date' +- +- ]; ++ 'participant_pass' => 'bool', ++ 'booking_date' => 'datetime', ++ 'start_date' => 'datetime', ++ 'end_date' => 'datetime', ++ 'participant_birthdate' => 'datetime', ++ 'final_payment_date' => 'datetime', ++ 'refund_date' => 'datetime', ++ 'lawyer_date' => 'datetime', ++ 'xx_tkt_date' => 'datetime', ++ ]; ++ ++ + + protected $fillable = [ + 'booking_date', diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Contact.php.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Contact.php.phase1-only.diff new file mode 100644 index 0000000..5252efa --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Contact.php.phase1-only.diff @@ -0,0 +1,170 @@ +diff --git a/app/Models/Contact.php b/app/Models/Contact.php +new file mode 100644 +index 0000000..6d941e8 +--- /dev/null ++++ b/app/Models/Contact.php +@@ -0,0 +1,164 @@ ++ 'int', ++ 'credit_card_type_id' => 'int', ++ 'country_id' => 'int', ++ 'merged_into_id' => 'int', ++ 'birthdate' => 'datetime', ++ 'credit_card_expiration_date' => 'datetime', ++ 'merged_at' => 'datetime', ++ ]; ++ ++ protected $fillable = [ ++ 'salutation_id', ++ 'title', ++ 'name', ++ 'firstname', ++ 'birthdate', ++ 'company', ++ 'street', ++ 'zip', ++ 'city', ++ 'email', ++ 'phone', ++ 'phonebusiness', ++ 'phonemobile', ++ 'fax', ++ 'bank', ++ 'bank_code', ++ 'bank_account_number', ++ 'credit_card_type_id', ++ 'credit_card_number', ++ 'credit_card_expiration_date', ++ 'participants_remarks', ++ 'miscellaneous_remarks', ++ 'country_id', ++ 'merged_into_id', ++ 'merged_at', ++ ]; ++ ++ /** ++ * Globaler Scope: zusammengeführte Duplikate werden standardmäßig ausgeblendet. ++ * Für Zugriff auf alle inkl. Duplikate: Contact::withoutGlobalScope('not_merged') ++ */ ++ protected static function booted(): void ++ { ++ static::addGlobalScope('not_merged', function (Builder $query) { ++ $query->whereNull('merged_into_id'); ++ }); ++ } ++ ++ // ── Beziehungen ────────────────────────────────────────────────────────── ++ ++ public function mergedInto(): \Illuminate\Database\Eloquent\Relations\BelongsTo ++ { ++ return $this->belongsTo(Contact::class, 'merged_into_id') ++ ->withoutGlobalScope('not_merged'); ++ } ++ ++ public function mergedContacts(): \Illuminate\Database\Eloquent\Relations\HasMany ++ { ++ return $this->hasMany(Contact::class, 'merged_into_id') ++ ->withoutGlobalScope('not_merged'); ++ } ++ ++ public function leads(): \Illuminate\Database\Eloquent\Relations\HasMany ++ { ++ return $this->hasMany(Lead::class, 'customer_id')->orderByDesc('created_at'); ++ } ++ ++ public function bookings(): \Illuminate\Database\Eloquent\Relations\HasMany ++ { ++ return $this->hasMany(Booking::class, 'customer_id')->orderByDesc('created_at'); ++ } ++ ++ public function salutation(): \Illuminate\Database\Eloquent\Relations\BelongsTo ++ { ++ return $this->belongsTo(Salutation::class); ++ } ++ ++ public function travel_country(): \Illuminate\Database\Eloquent\Relations\BelongsTo ++ { ++ return $this->belongsTo(TravelCountry::class, 'country_id'); ++ } ++ ++ // ── Hilfsmethoden ──────────────────────────────────────────────────────── ++ ++ public function fullName(): string ++ { ++ if ($this->firstname) { ++ return $this->firstname . ' ' . $this->name; ++ } ++ return (string) $this->name; ++ } ++ ++ public function isMerged(): bool ++ { ++ return $this->merged_into_id !== null; ++ } ++ ++ public static function getCountriesArray(): \Illuminate\Support\Collection ++ { ++ return TravelCountry::where('is_customer_country', 1)->get()->pluck('name', 'id'); ++ } ++ ++ public static $salutationType = [ ++ 1 => 'Herr', ++ 2 => 'Frau', ++ 3 => 'Divers/keine Anrede', ++ 4 => 'Firma', ++ ]; ++} diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Controllers.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Controllers.phase1-only.diff new file mode 100644 index 0000000..65c2c26 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Controllers.phase1-only.diff @@ -0,0 +1,372 @@ +diff --git a/app/Http/Controllers/API/BookingController.php b/app/Http/Controllers/API/BookingController.php +index e47cd07..a020ad4 100755 +--- a/app/Http/Controllers/API/BookingController.php ++++ b/app/Http/Controllers/API/BookingController.php +@@ -8,42 +8,35 @@ use App\Services\BookingImport; + + class BookingController extends Controller + { +- private $successStatus = 200; +- private $successKey = 'f6077389c9ce710e554763a5de02c8ec'; +- +- protected $draftRepo; ++ private int $successStatus = 200; ++ private string $successKey; + + public function __construct() + { +- ++ $this->successKey = config('app.success_key'); + } + + public function import() + { + + $request = \Request::all(); +- if(!isset($request['key']) || $request['key'] !== $this->successKey){ ++ if (!isset($request['key']) || $request['key'] !== $this->successKey) { + return response()->json(['error' => "key"], 401); + } + $travel_booking = TravelBooking::find($request['travel_booking_id']); + +- //# vor testing +- //$travel_booking = TravelBooking::find(2922); +- if(!isset($travel_booking) || !$travel_booking){ ++ if (!$travel_booking) { + return response()->json(['error' => 'no-booking-found'], $this->successStatus); + } + + $booking = BookingImport::importFrom($travel_booking); + +- $ret= [ +- 'url_v1' => make_old_url('/index.php/booking/'.$booking->id.'/edit'), ++ $ret = [ ++ 'url_v1' => make_old_url('/index.php/booking/' . $booking->id . '/edit'), + 'url_v3' => route('booking_detail', $booking->id), + 'lead_id' => $booking->lead_id + ]; + return response()->json(['success' => "import", "ret" => $ret], $this->successStatus); +- //return response()->json(['error' => 'no-node'], $this->successStatus); + + } +- +- + } +diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php +new file mode 100644 +index 0000000..08ef241 +--- /dev/null ++++ b/app/Http/Controllers/ContactController.php +@@ -0,0 +1,312 @@ ++middleware(['admin', '2fa']); ++ } ++ ++ public function index(): \Illuminate\View\View ++ { ++ return view('contact.index'); ++ } ++ ++ public function detail(string $id): \Illuminate\View\View ++ { ++ if ($id === 'new') { ++ $contact = new Contact(); ++ } else { ++ $contact = Contact::with(['salutation', 'leads', 'bookings', 'mergedContacts']) ++ ->findOrFail((int) $id); ++ } ++ ++ return view('contact.detail', [ ++ 'contact' => $contact, ++ 'id' => $id, ++ ]); ++ } ++ ++ public function store(Request $request, string $id): \Illuminate\Http\RedirectResponse ++ { ++ $data = $request->except('_token'); ++ ++ if (!isset($data['action'])) { ++ abort(403, 'keine Action'); ++ } ++ ++ if ($id === 'new') { ++ $contact = $this->contactRepo->createContact($data); ++ \Session()->flash('alert-save', '1'); ++ return redirect(route('contact_detail', [$contact->id]) . '#collapseContactDetail'); ++ } ++ ++ $this->contactRepo->updateContact($id, $data); ++ \Session()->flash('alert-save', '1'); ++ ++ return redirect(route('contact_detail', [$id]) . '#collapseContactDetail'); ++ } ++ ++ public function history(int $id): \Illuminate\View\View ++ { ++ $contact = Contact::with([ ++ 'leads.sf_guard_user', ++ 'bookings.travel_country', ++ 'bookings.travel_agenda', ++ 'bookings.sf_guard_user', ++ 'bookings.lead', ++ ])->findOrFail($id); ++ ++ return view('contact._detail_history', [ ++ 'contact' => $contact, ++ 'modal' => true, ++ ]); ++ } ++ ++ public function destroy(int $id): \Illuminate\Http\JsonResponse ++ { ++ $contact = Contact::withoutGlobalScope('not_merged')->findOrFail($id); ++ ++ $leadsCount = $contact->leads()->count(); ++ $bookingCount = $contact->bookings()->count(); ++ ++ if ($leadsCount > 0 || $bookingCount > 0) { ++ return response()->json([ ++ 'success' => false, ++ 'message' => sprintf( ++ 'Kontakt kann nicht gelöscht werden: %s%s vorhanden.', ++ $leadsCount > 0 ? $leadsCount . ' Anfrage(n)' : '', ++ $bookingCount > 0 ? ($leadsCount > 0 ? ' und ' : '') . $bookingCount . ' Buchung(en)' : '' ++ ), ++ ], 422); ++ } ++ ++ $contact->delete(); ++ ++ return response()->json(['success' => true]); ++ } ++ ++ public function duplicates(): \Illuminate\View\View ++ { ++ $counts = [ ++ 'HIGH' => $this->countDuplicateGroups('email'), ++ 'MEDIUM' => $this->countDuplicateGroups('name_birthdate'), ++ 'LOW' => $this->countDuplicateGroups('name_zip'), ++ ]; ++ ++ return view('contact.duplicates', compact('counts')); ++ } ++ ++ public function getDuplicateGroups(Request $request): \Illuminate\Http\JsonResponse ++ { ++ $confidence = strtoupper($request->input('confidence', 'HIGH')); ++ ++ $groups = match ($confidence) { ++ 'HIGH' => $this->findByEmail(), ++ 'MEDIUM' => $this->findByNameBirthdate(), ++ 'LOW' => $this->findByNameZip(), ++ default => [], ++ }; ++ ++ // Für jede Gruppe die vollständigen Kontakt-Daten laden ++ $result = []; ++ foreach ($groups as $ids) { ++ $contacts = Contact::withoutGlobalScopes() ++ ->withCount(['leads', 'bookings']) ++ ->whereIn('id', $ids) ++ ->whereNull('merged_into_id') ++ ->whereNull('deleted_at') ++ ->orderByRaw('FIELD(id, ' . implode(',', $ids) . ')') ++ ->get(['id', 'firstname', 'name', 'email', 'zip', 'city', 'phone', 'phonemobile', 'birthdate', 'created_at', 'updated_at']); ++ ++ if ($contacts->count() < 2) { ++ continue; ++ } ++ ++ $result[] = [ ++ 'master' => $contacts->first(), ++ 'duplicates' => $contacts->skip(1)->values(), ++ ]; ++ } ++ ++ return response()->json($result); ++ } ++ ++ public function merge(Request $request): \Illuminate\Http\JsonResponse ++ { ++ $masterId = (int) $request->input('master_id'); ++ $dupeId = (int) $request->input('duplicate_id'); ++ ++ if (!$masterId || !$dupeId || $masterId === $dupeId) { ++ return response()->json(['success' => false, 'message' => 'Ungültige IDs.'], 422); ++ } ++ ++ $master = Contact::withoutGlobalScopes()->find($masterId); ++ $dupe = Contact::withoutGlobalScopes()->find($dupeId); ++ ++ if (!$master || !$dupe) { ++ return response()->json(['success' => false, 'message' => 'Kontakt nicht gefunden.'], 404); ++ } ++ ++ DB::transaction(function () use ($masterId, $dupeId) { ++ DB::table('lead')->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([ ++ 'merged_into_id' => $masterId, ++ 'merged_at' => now(), ++ ]); ++ }); ++ ++ return response()->json(['success' => true]); ++ } ++ ++ // ── Duplikat-Hilfs-Queries ──────────────────────────────────────────────── ++ ++ 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(), ++ default => 0, ++ }; ++ } ++ ++ private function findByEmail(): array ++ { ++ return DB::table('customer') ++ ->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids') ++ ->whereNotNull('email')->where('email', '!=', '') ++ ->whereNull('merged_into_id')->whereNull('deleted_at') ++ ->groupBy('email')->havingRaw('COUNT(*) > 1') ++ ->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all(); ++ } ++ ++ private function findByNameBirthdate(): array ++ { ++ return DB::table('customer') ++ ->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') ++ ->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1') ++ ->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all(); ++ } ++ ++ private function findByNameZip(): array ++ { ++ return DB::table('customer') ++ ->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids') ++ ->whereNotNull('name')->whereNotNull('firstname') ++ ->whereNotNull('zip')->where('zip', '!=', '') ++ ->whereNull('merged_into_id')->whereNull('deleted_at') ++ ->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1') ++ ->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all(); ++ } ++ ++ public function restore(int $id): \Illuminate\Http\JsonResponse ++ { ++ $contact = Contact::onlyTrashed()->withoutGlobalScope('not_merged')->findOrFail($id); ++ $contact->restore(); ++ ++ return response()->json(['success' => true]); ++ } ++ ++ public function getContacts(Request $request): \Illuminate\Http\JsonResponse ++ { ++ $showDeleted = $request->filled('filter_deleted'); ++ ++ // 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']); ++ ++ // Zusatzfilter aus der UI (werden als extra GET-Parameter gesendet) ++ if ($request->filled('filter_has_leads')) { ++ $query->has('leads'); ++ } ++ if ($request->filled('filter_has_bookings')) { ++ $query->has('bookings'); ++ } ++ if ($request->filled('filter_has_email')) { ++ $query->whereNotNull('email')->where('email', '!=', ''); ++ } ++ ++ return DataTables::eloquent($query) ++ ->addColumn('action_edit', function (Contact $contact) use ($showDeleted) { ++ if ($showDeleted) { ++ return ''; ++ } ++ return ''; ++ }) ++ ->addColumn('action_delete', function (Contact $contact) use ($showDeleted) { ++ if ($showDeleted) { ++ return ''; ++ } ++ return ''; ++ }) ++ ->addColumn('id', function (Contact $contact) use ($showDeleted) { ++ if ($showDeleted) { ++ return '' . $contact->id . ''; ++ } ++ return '' . $contact->id . ''; ++ }) ++ ->addColumn('raw_id', fn(Contact $contact) => $contact->id) ++ ->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) { ++ if ($keyword !== '') { ++ $query->where('customer.id', 'LIKE', '%' . $keyword . '%'); ++ } ++ }) ++ ->filterColumn('name', function ($query, $keyword) { ++ if ($keyword !== '') { ++ $query->where(function ($q) use ($keyword) { ++ $q->where('name', 'LIKE', '%' . $keyword . '%') ++ ->orWhere('firstname', 'LIKE', '%' . $keyword . '%'); ++ }); ++ } ++ }) ++ ->filter(function ($query) use ($request) { ++ $location = $request->input('filter_location'); ++ if ($location && $location !== '') { ++ $query->where(function ($q) use ($location) { ++ $q->where('zip', 'LIKE', '%' . $location . '%') ++ ->orWhere('city', 'LIKE', '%' . $location . '%'); ++ }); ++ } ++ ++ $search = $request->input('search.value'); ++ if ($search && $search !== '') { ++ $query->where(function ($q) use ($search) { ++ $q->where('name', 'LIKE', '%' . $search . '%') ++ ->orWhere('firstname', 'LIKE', '%' . $search . '%') ++ ->orWhere('email', 'LIKE', '%' . $search . '%') ++ ->orWhere('phone', 'LIKE', '%' . $search . '%') ++ ->orWhere('phonemobile', 'LIKE', '%' . $search . '%'); ++ }); ++ } ++ }, true) ++ ->rawColumns(['action_edit', 'action_delete', 'id']) ++ ->make(true); ++ } ++} diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Customer.php.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Customer.php.phase1-only.diff new file mode 100644 index 0000000..10750a2 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Customer.php.phase1-only.diff @@ -0,0 +1,40 @@ +diff --git a/app/Models/Customer.php b/app/Models/Customer.php +index 94e618e..436b3ee 100644 +--- a/app/Models/Customer.php ++++ b/app/Models/Customer.php +@@ -9,6 +9,7 @@ namespace App\Models; + use App\Models\Sym\TravelCountry; + use Carbon\Carbon; + use Illuminate\Database\Eloquent\Collection; ++use Illuminate\Database\Eloquent\Factories\HasFactory; + use Illuminate\Database\Eloquent\Model; + + /** +@@ -83,6 +84,8 @@ use Illuminate\Database\Eloquent\Model; + */ + class Customer extends Model + { ++ use HasFactory; ++ + protected $connection = 'mysql'; + + protected $table = 'customer'; +@@ -90,13 +93,12 @@ class Customer extends Model + protected $casts = [ + 'salutation_id' => 'int', + 'credit_card_type_id' => 'int', +- 'country_id' => 'int' +- ]; ++ 'country_id' => 'int', ++ 'birthdate' => 'datetime', ++ 'credit_card_expiration_date' => 'datetime', ++ ]; + +- protected $dates = [ +- 'birthdate', +- 'credit_card_expiration_date' +- ]; ++ + + protected $fillable = [ + 'salutation_id', diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Lead.php.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Lead.php.phase1-only.diff new file mode 100644 index 0000000..3f81f4e --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Lead.php.phase1-only.diff @@ -0,0 +1,44 @@ +diff --git a/app/Models/Lead.php b/app/Models/Lead.php +index 8b84b9e..227bd38 100644 +--- a/app/Models/Lead.php ++++ b/app/Models/Lead.php +@@ -9,6 +9,7 @@ namespace App\Models; + use Carbon\Carbon; + use App\Services\Passolution; + use App\Models\Lead as ModelsLead; ++use Illuminate\Database\Eloquent\Factories\HasFactory; + use Illuminate\Database\Eloquent\Model; + use Illuminate\Database\Eloquent\Collection; + +@@ -107,6 +108,8 @@ use Illuminate\Database\Eloquent\Collection; + */ + class Lead extends Model + { ++ use HasFactory; ++ + protected $connection = 'mysql'; + + protected $table = 'lead'; +@@ -126,16 +129,14 @@ class Lead extends Model + 'travelcategory_id' => 'int', + 'price' => 'float', + 'pax' => 'int', +- 'participant_salutation_id' => 'int' ++ 'participant_salutation_id' => 'int', ++ 'request_date' => 'datetime', ++ 'travelperiod_start' => 'datetime', ++ 'travelperiod_end' => 'datetime', ++ 'next_due_date' => 'datetime', ++ 'participant_birthdate' => 'datetime', + ]; + +- protected $dates = [ +- 'request_date', +- 'travelperiod_start', +- 'travelperiod_end', +- 'next_due_date', +- 'participant_birthdate' +- ]; + + protected $fillable = [ + 'customer_id', diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.full.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.full.diff new file mode 100644 index 0000000..e4e519b --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.full.diff @@ -0,0 +1,159 @@ +diff --git a/app/Repositories/BookingPDFRepository.php b/app/Repositories/BookingPDFRepository.php +index 505ddaf..e5ba139 100644 +--- a/app/Repositories/BookingPDFRepository.php ++++ b/app/Repositories/BookingPDFRepository.php +@@ -22,7 +22,7 @@ class BookingPDFRepository extends BaseRepository + public function __construct(Booking $model) + { + $this->model = $model; +- $this->prepath = Storage::disk('public')->getAdapter()->getPathPrefix(); ++ $this->prepath = Storage::disk('public')->path(''); + } + + public function update($data) +@@ -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, +diff --git a/app/Repositories/CustomerMailRepository.php b/app/Repositories/CustomerMailRepository.php +index c1b1226..6397f0c 100644 +--- a/app/Repositories/CustomerMailRepository.php ++++ b/app/Repositories/CustomerMailRepository.php +@@ -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'])){ +diff --git a/app/Repositories/LeadRepository.php b/app/Repositories/LeadRepository.php +index 929426d..ad0ac87 100644 +--- a/app/Repositories/LeadRepository.php ++++ b/app/Repositories/LeadRepository.php +@@ -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, diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.phase1-only.diff new file mode 100644 index 0000000..932b124 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Repositories.phase1-only.diff @@ -0,0 +1,13 @@ +diff --git a/app/Repositories/BookingPDFRepository.php b/app/Repositories/BookingPDFRepository.php +index 505ddaf..dc4d313 100644 +--- a/app/Repositories/BookingPDFRepository.php ++++ b/app/Repositories/BookingPDFRepository.php +@@ -22,7 +22,7 @@ class BookingPDFRepository extends BaseRepository + public function __construct(Booking $model) + { + $this->model = $model; +- $this->prepath = Storage::disk('public')->getAdapter()->getPathPrefix(); ++ $this->prepath = Storage::disk('public')->path(''); + } + + public function update($data) diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Services-Commands.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Services-Commands.phase1-only.diff new file mode 100644 index 0000000..88694fe --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/Services-Commands.phase1-only.diff @@ -0,0 +1,405 @@ +diff --git a/app/Console/Commands/ContactsFindDuplicates.php b/app/Console/Commands/ContactsFindDuplicates.php +new file mode 100644 +index 0000000..cef982d +--- /dev/null ++++ b/app/Console/Commands/ContactsFindDuplicates.php +@@ -0,0 +1,160 @@ ++info('Suche nach Duplikaten in der customer-Tabelle...'); ++ $this->newLine(); ++ ++ $groups = collect(); ++ ++ // ── HIGH: gleiche E-Mail ────────────────────────────────────────── ++ if ($this->shouldCheck('HIGH')) { ++ $emailDupes = DB::table('customer') ++ ->select('email', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids')) ++ ->whereNotNull('email') ++ ->where('email', '!=', '') ++ ->whereNull('merged_into_id') ++ ->groupBy('email') ++ ->having('cnt', '>', 1) ++ ->get(); ++ ++ foreach ($emailDupes as $row) { ++ $groups->push([ ++ 'confidence' => 'HIGH', ++ 'reason' => 'E-Mail: ' . $row->email, ++ 'ids' => $row->ids, ++ 'count' => $row->cnt, ++ ]); ++ } ++ ++ $this->line(sprintf('HIGH (gleiche E-Mail): %d Gruppen', $emailDupes->count())); ++ } ++ ++ // ── MEDIUM: Name + Vorname + Geburtsdatum ──────────────────────── ++ if ($this->shouldCheck('MEDIUM')) { ++ $nameBdDupes = DB::table('customer') ++ ->select( ++ 'name', 'firstname', 'birthdate', ++ DB::raw('COUNT(*) as cnt'), ++ DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids') ++ ) ++ ->whereNotNull('name') ++ ->whereNotNull('firstname') ++ ->whereNotNull('birthdate') ++ ->whereNull('merged_into_id') ++ ->groupBy('name', 'firstname', 'birthdate') ++ ->having('cnt', '>', 1) ++ ->get(); ++ ++ foreach ($nameBdDupes as $row) { ++ $groups->push([ ++ 'confidence' => 'MEDIUM', ++ 'reason' => "Name: {$row->firstname} {$row->name}, GD: {$row->birthdate}", ++ 'ids' => $row->ids, ++ 'count' => $row->cnt, ++ ]); ++ } ++ ++ $this->line(sprintf('MEDIUM (Name+GD): %d Gruppen', $nameBdDupes->count())); ++ } ++ ++ // ── LOW: Name + Vorname + PLZ ───────────────────────────────────── ++ if ($this->shouldCheck('LOW')) { ++ $nameZipDupes = DB::table('customer') ++ ->select( ++ 'name', 'firstname', 'zip', ++ DB::raw('COUNT(*) as cnt'), ++ DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids') ++ ) ++ ->whereNotNull('name') ++ ->whereNotNull('firstname') ++ ->whereNotNull('zip') ++ ->where('zip', '!=', '') ++ ->whereNull('merged_into_id') ++ ->groupBy('name', 'firstname', 'zip') ++ ->having('cnt', '>', 1) ++ ->get(); ++ ++ foreach ($nameZipDupes as $row) { ++ $groups->push([ ++ 'confidence' => 'LOW', ++ 'reason' => "Name: {$row->firstname} {$row->name}, PLZ: {$row->zip}", ++ 'ids' => $row->ids, ++ 'count' => $row->cnt, ++ ]); ++ } ++ ++ $this->line(sprintf('LOW (Name+PLZ): %d Gruppen', $nameZipDupes->count())); ++ } ++ ++ $this->newLine(); ++ $this->info(sprintf('Gesamt: %d Duplikat-Gruppen gefunden', $groups->count())); ++ ++ if ($groups->isEmpty()) { ++ $this->info('Keine Duplikate — nichts zu tun.'); ++ return self::SUCCESS; ++ } ++ ++ // Tabellen-Ausgabe ++ $this->table( ++ ['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl'], ++ $groups->map(fn ($g) => [$g['confidence'], $g['reason'], $g['ids'], $g['count']])->all() ++ ); ++ ++ // CSV-Export ++ if ($export = $this->option('export')) { ++ $this->exportCsv($groups->all(), $export); ++ $this->info("CSV gespeichert: {$export}"); ++ } else { ++ $this->newLine(); ++ $this->line('Tipp: --export=duplicates.csv für CSV-Export'); ++ $this->line('Tipp: php artisan contacts:merge-duplicates --dry-run zum Prüfen'); ++ } ++ ++ return self::SUCCESS; ++ } ++ ++ private function shouldCheck(string $level): bool ++ { ++ $filter = strtoupper((string) $this->option('confidence')); ++ return $filter === '' || $filter === $level; ++ } ++ ++ private function exportCsv(array $groups, string $path): void ++ { ++ $handle = fopen($path, 'w'); ++ fputcsv($handle, ['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl']); ++ foreach ($groups as $group) { ++ fputcsv($handle, [$group['confidence'], $group['reason'], $group['ids'], $group['count']]); ++ } ++ fclose($handle); ++ } ++} +diff --git a/app/Console/Commands/ContactsMergeDuplicates.php b/app/Console/Commands/ContactsMergeDuplicates.php +new file mode 100644 +index 0000000..e51ca46 +--- /dev/null ++++ b/app/Console/Commands/ContactsMergeDuplicates.php +@@ -0,0 +1,233 @@ ++dryRun = (bool) $this->option('dry-run'); ++ ++ if ($this->dryRun) { ++ $this->warn('DRY-RUN Modus — keine Daten werden verändert'); ++ } else { ++ $this->warn('ACHTUNG: Diese Operation verändert Produktionsdaten.'); ++ if (!$this->option('force') && !$this->confirm('Fortfahren?')) { ++ $this->info('Abgebrochen.'); ++ return self::SUCCESS; ++ } ++ } ++ ++ $this->newLine(); ++ ++ DB::transaction(function () { ++ $this->processLevel( ++ 'HIGH', ++ fn () => $this->findByEmail() ++ ); ++ ++ $this->processLevel( ++ 'MEDIUM', ++ fn () => $this->findByNameBirthdate() ++ ); ++ ++ $this->processLevel( ++ 'LOW', ++ fn () => $this->findByNameZip() ++ ); ++ }); ++ ++ $this->newLine(); ++ $this->info(sprintf( ++ '%s %d Duplikate zusammengeführt | %d leads aktualisiert | %d bookings aktualisiert', ++ $this->dryRun ? '[DRY-RUN]' : '', ++ $this->mergedCount, ++ $this->updatedLeads, ++ $this->updatedBookings ++ )); ++ ++ if ($this->dryRun) { ++ $this->newLine(); ++ $this->line('Zum Ausführen: php artisan contacts:merge-duplicates'); ++ } ++ ++ return self::SUCCESS; ++ } ++ ++ // ───────────────────────────────────────────────────────────────────────── ++ // Duplikat-Gruppen ermitteln ++ // ───────────────────────────────────────────────────────────────────────── ++ ++ private function findByEmail(): array ++ { ++ return DB::table('customer') ++ ->select('email', DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')) ++ ->whereNotNull('email') ++ ->where('email', '!=', '') ++ ->whereNull('merged_into_id') ++ ->groupBy('email') ++ ->having(DB::raw('COUNT(*)'), '>', 1) ++ ->pluck('ids') ++ ->map(fn ($ids) => explode(',', $ids)) ++ ->all(); ++ } ++ ++ private function findByNameBirthdate(): array ++ { ++ return DB::table('customer') ++ ->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')) ++ ->whereNotNull('name') ++ ->whereNotNull('firstname') ++ ->whereNotNull('birthdate') ++ ->whereNull('merged_into_id') ++ ->groupBy('name', 'firstname', 'birthdate') ++ ->having(DB::raw('COUNT(*)'), '>', 1) ++ ->pluck('ids') ++ ->map(fn ($ids) => explode(',', $ids)) ++ ->all(); ++ } ++ ++ private function findByNameZip(): array ++ { ++ return DB::table('customer') ++ ->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')) ++ ->whereNotNull('name') ++ ->whereNotNull('firstname') ++ ->whereNotNull('zip') ++ ->where('zip', '!=', '') ++ ->whereNull('merged_into_id') ++ ->groupBy('name', 'firstname', 'zip') ++ ->having(DB::raw('COUNT(*)'), '>', 1) ++ ->pluck('ids') ++ ->map(fn ($ids) => explode(',', $ids)) ++ ->all(); ++ } ++ ++ // ───────────────────────────────────────────────────────────────────────── ++ // Verarbeitung ++ // ───────────────────────────────────────────────────────────────────────── ++ ++ private function processLevel(string $level, callable $finder): void ++ { ++ if ($filter = $this->option('confidence')) { ++ if (strtoupper($filter) !== $level) { ++ return; ++ } ++ } ++ ++ $groups = $finder(); ++ ++ if (empty($groups)) { ++ $this->line("[{$level}] Keine Duplikate."); ++ return; ++ } ++ ++ $this->line(sprintf('[%s] %d Gruppe(n) gefunden', $level, count($groups))); ++ ++ foreach ($groups as $ids) { ++ $masterId = (int) $ids[0]; // erster = neuester ++ $duplicateIds = array_map('intval', array_slice($ids, 1)); ++ ++ $this->line(sprintf( ++ ' Master: #%d ← Duplikate: %s', ++ $masterId, ++ implode(', ', array_map(fn ($id) => '#' . $id, $duplicateIds)) ++ )); ++ ++ foreach ($duplicateIds as $dupeId) { ++ $this->mergeInto($masterId, $dupeId); ++ } ++ } ++ } ++ ++ private function mergeInto(int $masterId, int $dupeId): void ++ { ++ // 1. Leads umhängen ++ $leadCount = DB::table('lead')->where('customer_id', $dupeId)->count(); ++ if ($leadCount > 0) { ++ $this->line(" lead.customer_id: {$leadCount} Zeile(n) → #{$masterId}"); ++ if (!$this->dryRun) { ++ DB::table('lead') ++ ->where('customer_id', $dupeId) ++ ->update(['customer_id' => $masterId]); ++ } ++ $this->updatedLeads += $leadCount; ++ } ++ ++ // 2. Bookings umhängen ++ $bookingCount = DB::table('booking')->where('customer_id', $dupeId)->count(); ++ if ($bookingCount > 0) { ++ $this->line(" booking.customer_id: {$bookingCount} Zeile(n) → #{$masterId}"); ++ if (!$this->dryRun) { ++ DB::table('booking') ++ ->where('customer_id', $dupeId) ++ ->update(['customer_id' => $masterId]); ++ } ++ $this->updatedBookings += $bookingCount; ++ } ++ ++ // 3. customer_mails umhängen ++ $mailCount = DB::table('customer_mails')->where('customer_id', $dupeId)->count(); ++ if ($mailCount > 0) { ++ $this->line(" customer_mails.customer_id: {$mailCount} Zeile(n) → #{$masterId}"); ++ if (!$this->dryRun) { ++ DB::table('customer_mails') ++ ->where('customer_id', $dupeId) ++ ->update(['customer_id' => $masterId]); ++ } ++ } ++ ++ // 4. lead_mails umhängen ++ $leadMailCount = DB::table('lead_mails')->where('customer_id', $dupeId)->count(); ++ if ($leadMailCount > 0) { ++ $this->line(" lead_mails.customer_id: {$leadMailCount} Zeile(n) → #{$masterId}"); ++ if (!$this->dryRun) { ++ DB::table('lead_mails') ++ ->where('customer_id', $dupeId) ++ ->update(['customer_id' => $masterId]); ++ } ++ } ++ ++ // 5. Duplikat als zusammengeführt markieren ++ if (!$this->dryRun) { ++ DB::table('customer') ++ ->where('id', $dupeId) ++ ->update([ ++ 'merged_into_id' => $masterId, ++ 'merged_at' => now(), ++ ]); ++ } ++ ++ $this->mergedCount++; ++ } ++} diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/Views.phase1-only.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/Views.phase1-only.diff new file mode 100644 index 0000000..e69de29 diff --git a/dev/backups/phase2-offers-2026-04-17/PATCHES/filesystems.php.full.diff b/dev/backups/phase2-offers-2026-04-17/PATCHES/filesystems.php.full.diff new file mode 100644 index 0000000..75218f9 --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/PATCHES/filesystems.php.full.diff @@ -0,0 +1,17 @@ +diff --git a/config/filesystems.php b/config/filesystems.php +index 2318ca2..97762c8 100755 +--- a/config/filesystems.php ++++ b/config/filesystems.php +@@ -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'), diff --git a/dev/backups/phase2-offers-2026-04-17/restore.sh b/dev/backups/phase2-offers-2026-04-17/restore.sh new file mode 100755 index 0000000..8c3afda --- /dev/null +++ b/dev/backups/phase2-offers-2026-04-17/restore.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# Restore-Script: spielt Phase 2 + Offers-Änderungen zurück in den Workspace. +# Voraussetzung: Phase 1 wurde erfolgreich auf Live deployed und migriert. +# +# Nutzung: +# bash dev/backups/phase2-offers-2026-04-17/restore.sh # interaktiv +# bash dev/backups/phase2-offers-2026-04-17/restore.sh --force # ohne Nachfrage +# +# Details siehe MANIFEST.md im selben Verzeichnis. + +set -euo pipefail + +BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${BACKUP_DIR}/../../../" && pwd)" +FORCE=false + +for arg in "$@"; do + case "$arg" in + --force|-f) FORCE=true ;; + *) echo "Unbekannte Option: $arg"; exit 1 ;; + esac +done + +cd "${PROJECT_ROOT}" + +echo "==========================================" +echo " Phase-2 + Offers Restore" +echo "==========================================" +echo +echo " Projekt-Root: ${PROJECT_ROOT}" +echo " Backup-Ordner: ${BACKUP_DIR}" +echo +echo " Folgende Änderungen werden zurückgespielt:" +echo " - 18 Migrationen (Phase 2, 3, 4, Offers) nach database/migrations/" +echo " - 6 Offer-Models nach app/Models/ (inkl. Überschreiben von Legacy-Offer.php)" +echo " - Code-Reverts via git checkout \${WIP_COMMIT} -- " +echo + +WIP_COMMIT="e3dc1af" +if ! git cat-file -e "${WIP_COMMIT}" 2>/dev/null; then + echo "FEHLER: Commit ${WIP_COMMIT} existiert nicht in diesem Repo." + echo " Wenn das Repo neu geklont wurde, musst du stattdessen manuell" + echo " die Dateien aus dem Tarball wiederherstellen." + echo " Siehe MANIFEST.md → Abschnitt 'Manuell'." + exit 2 +fi + +if ! ${FORCE}; then + echo + read -r -p " Wurde Phase 1 auf Live erfolgreich deployed und migriert? (yes/NO): " answer + if [[ "${answer}" != "yes" ]]; then + echo " Abbruch. Bitte erst Phase 1 auf Live einspielen." + exit 3 + fi + echo + read -r -p " Trotzdem fortfahren? (yes/NO): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo " Abbruch durch User." + exit 3 + fi +fi + +echo +echo "--- 1/4 --- Migrationen zurückspielen" +cp -v "${BACKUP_DIR}/FILES/migrations/"*.php database/migrations/ +echo " → $(ls "${BACKUP_DIR}/FILES/migrations/" | wc -l) Migrationen kopiert" + +echo +echo "--- 2/4 --- Offer-Models zurückspielen" +cp -v "${BACKUP_DIR}/FILES/models/"*.php app/Models/ +echo " → 6 Models kopiert (inkl. Überschreiben Legacy-Offer.php)" + +echo +echo "--- 3/4 --- Code-Reverts aus Git-Commit ${WIP_COMMIT}" +FILES_TO_RESTORE=( + "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/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/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/pdf/components/booking_head.blade.php" + "resources/views/pdf/components/booking_header.blade.php" + "config/filesystems.php" +) +for f in "${FILES_TO_RESTORE[@]}"; do + git checkout "${WIP_COMMIT}" -- "${f}" + echo " ↺ ${f}" +done + +echo +echo "--- 4/4 --- Verifikation" +echo +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):" +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 "==========================================" +echo " Restore abgeschlossen." +echo "==========================================" +echo +echo " Nächste Schritte:" +echo " 1. php -l auf allen geänderten Dateien (Syntax-Check)" +echo " 2. composer dump-autoload (neue Models registrieren)" +echo " 3. Weiterarbeit an Phase 2 + Offers nach Plan" +echo " 4. Phase-2-Migrationen ausführen:" +echo " php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php" +echo " php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php" +echo " php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php" +echo diff --git a/dev/customer-bookings/phase-1-live-deploy.md b/dev/customer-bookings/phase-1-live-deploy.md new file mode 100644 index 0000000..fabc883 --- /dev/null +++ b/dev/customer-bookings/phase-1-live-deploy.md @@ -0,0 +1,241 @@ +# Phase-1-Live-Deploy — Anleitung + +**Ausgangssituation:** +- Test-System läuft mit Phase 1 (Laravel-10-Upgrade, Contacts-Modul, Duplicats-Commands, Soft-Delete- & Merge-Fields auf `customer`-Tabelle) +- Live-Server ist bei Git-Commit `389d5d1` (Januar 2026) — noch ohne Phase 1 +- Der Workspace wurde per `dev/backups/phase2-offers-2026-04-17/` um Phase 2 + Offers bereinigt und ist jetzt **exakt auf Phase-1-Stand** wie Test + +--- + +## Vorbereitung (einmalig) + +1. **Backup auf Live-Server** + - Vollständiges DB-Backup (mysqldump oder Hoster-Tool) — speichere mit Zeitstempel + - Vollständiges Dateisystem-Backup (zumindest `/app`, `/config`, `/routes`, `/resources`, `/database/migrations`, `/composer.json`, `/composer.lock`) + +2. **Maintenance-Mode aktivieren** (empfohlen für 15–30 min) + ```bash + php artisan down --render="errors::503" --secret="dein-geheimer-preview-token" + ``` + Mit `--secret` kannst du über `https://domain/dein-geheimer-preview-token` weiter auf Live zugreifen, während alle anderen die 503-Seite sehen. + +3. **Abhängigkeiten abklären** + - Läuft auf Live mindestens **PHP 8.1** (Laravel-10-Anforderung)? Prüfen: `php -v` + - Ist **Composer** auf dem Live-Server installiert? Falls Managed Hosting: `composer install` vor dem Deploy auf einem Staging-Server ausführen und das `vendor/`-Verzeichnis mitsyncen + +--- + +## Upload (rsync / scp / SFTP) + +### Was **muss** hochgeladen werden + +**Neue Dateien:** +``` +database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php +database/migrations/2025_04_15_100002_phase1_add_soft_delete_to_customer_table.php +app/Models/Contact.php +app/Repositories/ContactRepository.php +app/Http/Controllers/ContactController.php +app/Console/Commands/ContactsFindDuplicates.php +app/Console/Commands/ContactsMergeDuplicates.php +app/Services/MailDirService.php +resources/views/contact/_detail_contact.blade.php +resources/views/contact/_detail_history.blade.php +resources/views/contact/detail.blade.php +resources/views/contact/duplicates.blade.php +resources/views/contact/index.blade.php +database/factories/BookingFactory.php +database/factories/CustomerFactory.php +database/factories/LeadFactory.php +tests/Feature/Api/BookingImportTest.php +tests/Feature/Auth/LoginTest.php +tests/Feature/BookingControllerTest.php +tests/Unit/Services/UtilTest.php +``` + +**Modifizierte Dateien** — vollständige Upload-Liste generieren: + +```bash +cd /workspace/mein.sterntours.de +git diff --name-only HEAD^ \ + | grep -Ev "^(dev/|tests/|\.env|\.mcp\.json$|CLAUDE\.md$|boost\.json$|_ide_helper|\.devcontainer/|bootstrap/cache/|mein\.sterntours\.de\.code-workspace$|docker-compose\.yml$|public/storage$)" \ + > /tmp/phase1-files-to-upload.txt +cat /tmp/phase1-files-to-upload.txt +wc -l /tmp/phase1-files-to-upload.txt +``` + +Das filtert die unerwünschten Dateien automatisch raus. + +Die wichtigsten modifizierten Dateien (komplette Liste in der generierten Textdatei): +- `app/Models/Customer.php`, `Booking.php`, `Lead.php` (und viele weitere Models — Laravel-10-Upgrade) +- `app/Http/Controllers/*.php` (Controllers mit Laravel-10-Anpassungen) +- `app/Http/Kernel.php`, `app/Console/Kernel.php`, `app/Providers/*.php`, `app/Http/Middleware/TrustProxies.php` (Laravel-10-Upgrade) +- `app/Repositories/*.php` (Laravel-10: `Storage::disk()->path()` statt `getAdapter()->getPathPrefix()`) +- `app/Libraries/CreatePDF.php` +- `routes/web.php` (neue `/contacts`-Routen) +- `resources/views/layouts/application.blade.php`, `resources/views/layouts/includes/layout-sidenav.blade.php` +- `config/trustedproxy.php` +- `packages/digital-bird/shoppingcart/**`, `packages/iqcontent/laravel-filemanager/**` (Package-Updates) +- `composer.json`, `composer.lock` +- `phpunit.xml` + +### Was **nicht** hochgeladen werden darf + +- **Alles unter `dev/`** — dort liegt Entwicklungsdokumentation und die Backups (Phase 2, Offers) +- `.env`, `.env.*` — enthält produktive Credentials, wird separat verwaltet +- `_ide_helper.php`, `_ide_helper_models.php` — lokale IDE-Hilfen (von barryvdh/laravel-ide-helper generiert) +- `bootstrap/cache/config.php` — wird auf Live per `php artisan config:cache` frisch generiert +- `.mcp.json`, `CLAUDE.md`, `boost.json` — Dev-Tools-Konfiguration +- `.devcontainer/` — Entwicklungsumgebung (VSCode-Devcontainer) +- `storage/logs/*`, `storage/framework/cache/*`, `storage/framework/views/*`, `storage/framework/sessions/*` — Runtime-Daten +- `vendor/` — wird auf Live per `composer install` generiert (außer Managed Hosting, siehe oben) +- `node_modules/` — genauso +- `.git/` — Repo-Metadaten +- `tests/` — nur auf Test relevant; auf Live-Production nicht nötig (aber nicht schädlich, falls mit rauf kommt) +- `mein.sterntours.de.code-workspace` — IDE-Workspace-Datei +- `docker-compose.yml` — lokale Dev-Orchestrierung + +### Beispiel rsync-Kommando (als Orientierung) + +```bash +# Von lokal → Live +# Achtung: --dry-run zuerst, dann ohne --dry-run wiederholen! +rsync -av --dry-run \ + --exclude='.git/' \ + --exclude='dev/' \ + --exclude='node_modules/' \ + --exclude='vendor/' \ + --exclude='storage/logs/' \ + --exclude='storage/framework/cache/' \ + --exclude='storage/framework/views/' \ + --exclude='storage/framework/sessions/' \ + --exclude='storage/app/public/temp/' \ + --exclude='public/storage' \ + --exclude='.env*' \ + --exclude='bootstrap/cache/' \ + --exclude='_ide_helper*' \ + --exclude='.mcp.json' \ + --exclude='CLAUDE.md' \ + --exclude='boost.json' \ + --exclude='.devcontainer/' \ + /workspace/mein.sterntours.de/ \ + user@live-server:/pfad/zu/mein.sterntours.de/ +``` + +Falls du **nur geänderte Dateien** syncen möchtest (sicherer bei großer Codebase): +```bash +git diff --name-only HEAD^ | grep -v "^dev/" > /tmp/phase1-files.txt +rsync -av --files-from=/tmp/phase1-files.txt . user@live-server:/pfad/zu/mein.sterntours.de/ +``` + +--- + +## Ausführung auf Live-Server + +Nach dem Upload, per SSH auf dem Live-Server: + +```bash +cd /pfad/zu/mein.sterntours.de + +# 1. Composer Dependencies aktualisieren (Laravel 10 Upgrade!) +composer install --no-dev --optimize-autoloader + +# 2. Alle Caches leeren (wichtig wegen Struktur-Änderungen) +php artisan config:clear +php artisan route:clear +php artisan view:clear +php artisan cache:clear + +# 3. DB-Migrationen ausführen (nur die 2 Phase-1-Migrationen) +php artisan migrate --force +# Sollte zeigen: +# Running: 2025_04_15_100001_phase1_add_merge_fields_to_customer_table +# Running: 2025_04_15_100002_phase1_add_soft_delete_to_customer_table + +# 4. Autoload neu generieren (neue Klassen: Contact, ContactRepository, MailDirService) +composer dump-autoload --optimize + +# 5. Duplicats-Analyse (read-only, erzeugt Reports) +php artisan contacts:find-duplicates +# → Reports unter storage/app/contacts/duplicates/*.csv + +# 6. Duplicats-Merging (DRY-RUN zuerst!) +php artisan contacts:merge-duplicates --dry-run +# Review der Ausgabe. Wenn OK: + +# 7. Duplicats-Merging (echt) +php artisan contacts:merge-duplicates --confidence=HIGH --force +# Merged nur hochkonfidente Duplikate automatisch. +# Mittlere/niedrige Konfidenz bleibt für manuelle Review. + +# 8. Production-Caches wieder aufbauen (für Performance) +php artisan config:cache +php artisan route:cache +php artisan view:cache +# Optional: php artisan event:cache +# NICHT php artisan optimize (das cached auch, aber einige Setups haben damit Probleme) + +# 9. Maintenance-Mode deaktivieren +php artisan up +``` + +--- + +## Smoke-Tests nach dem Deploy + +Nach dem `php artisan up` diese Kernfunktionen manuell testen: + +- [ ] Login funktioniert (`/login`) +- [ ] Buchungsliste lädt (`/booking`) +- [ ] Einzelne Buchung öffnen, Detailseite lädt +- [ ] Anfragenliste lädt (`/lead`) +- [ ] Einzelne Anfrage öffnen, Detailseite lädt +- [ ] **NEU**: Kontakte-Liste lädt (`/contacts` oder `/contact` je nach Route) +- [ ] **NEU**: Kontakte-Duplikats-Übersicht lädt (`/contacts/duplicates` oder ähnlich) +- [ ] PDF-Erzeugung funktioniert (Buchungsbestätigung, Voucher, Storno) — testet `Storage::disk()->path()`-Änderung +- [ ] Mail-Versand über CustomerMail funktioniert +- [ ] Admin-Report läuft durch (`/admin/report`) + +--- + +## Rollback-Plan (falls etwas schiefgeht) + +1. **Maintenance-Mode aktivieren:** `php artisan down` +2. **DB-Rollback:** + ```bash + php artisan migrate:rollback --step=2 + ``` + oder — falls das fehlschlägt — das zuvor erstellte DB-Backup einspielen. +3. **Dateisystem-Rollback:** das zuvor erstellte Dateisystem-Backup zurückspielen (überschreiben). +4. **Caches leeren + Up:** + ```bash + php artisan config:clear && php artisan cache:clear && php artisan view:clear && php artisan up + ``` + +--- + +## Nach erfolgreichem Live-Deploy + +1. **Lokal** im Workspace: Restore-Script laufen lassen, um Phase 2 + Offers zurückzuholen: + ```bash + cd /workspace/mein.sterntours.de + bash dev/backups/phase2-offers-2026-04-17/restore.sh + ``` +2. Der Workspace enthält dann wieder Phase 2 Code + Offers-Modul. Weitermachen nach Plan in: + - `dev/customer-bookings/umsetzung.md` (Phase 2–4) + - `dev/offers/umsetzung.md` (Offers-Modul) +3. **Master-Branch** im Git kann bei Gelegenheit mit dem WIP-Commit `e3dc1af` aktualisiert werden (push), damit der Remote-Stand den aktuellen Arbeitsstand abbildet. + +--- + +## Dateien in diesem Backup + +- `dev/backups/phase2-offers-2026-04-17/MANIFEST.md` — detaillierte Übersicht +- `dev/backups/phase2-offers-2026-04-17/FILES/migrations/` — 18 Migrations-Dateien +- `dev/backups/phase2-offers-2026-04-17/FILES/models/` — 6 Offer-Models +- `dev/backups/phase2-offers-2026-04-17/PATCHES/` — 11 Diff-Dateien zur Dokumentation +- `dev/backups/phase2-offers-2026-04-17/restore.sh` — automatisches Restore + +**Zusätzliche Sicherheitsnetze:** +- Tarball: `../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz` (182 MB, kompletter Workspace-State vor dem Rückbau) +- Git-Commit: `e3dc1af` auf lokalem `master` (nicht gepusht) — enthält Phase 1 + 2 + Offers gemischt diff --git a/dev/customer-bookings/phase-2-live-deploy.md b/dev/customer-bookings/phase-2-live-deploy.md new file mode 100644 index 0000000..fe367a1 --- /dev/null +++ b/dev/customer-bookings/phase-2-live-deploy.md @@ -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 25–29, 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 ~10–20 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 24–48 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 -- 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. diff --git a/dev/customer-bookings/umsetzung.md b/dev/customer-bookings/umsetzung.md index eccfa6d..0b88e59 100644 --- a/dev/customer-bookings/umsetzung.md +++ b/dev/customer-bookings/umsetzung.md @@ -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 -**Konzept:** [konzept.md](konzept.md) +**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 27–29** | ⬜ 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 249–253: `$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 27–29):** ```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 diff --git a/resources/views/booking/_detail_price.blade.php b/resources/views/booking/_detail_price.blade.php index 869e6e2..cd48819 100755 --- a/resources/views/booking/_detail_price.blade.php +++ b/resources/views/booking/_detail_price.blade.php @@ -121,6 +121,25 @@ + @php + $priceTotalStored = $booking->getPriceTotalRaw(); + $priceTotalComputed = round((float) $booking->getPriceRaw() + (float) $booking->getServiceTotal(true), 2); + @endphp +
+

+ price_total (gespeichert in DB): + {{ \App\Services\Util::_number_format($priceTotalStored ?? 0) }} € + · + rechnerisch (Organisation + Vermittlung): + {{ \App\Services\Util::_number_format($priceTotalComputed) }} € + @if($booking->isCanceled() && $booking->getPriceCanceledRaw() !== null) + · + laut Storno-Logik (= Storno-Betrag price_canceled): + {{ \App\Services\Util::_number_format((float) $booking->getPriceCanceledRaw()) }} € + @endif +

+
+

diff --git a/resources/views/contact/index.blade.php b/resources/views/contact/index.blade.php index b327d3d..e02edb4 100644 --- a/resources/views/contact/index.blade.php +++ b/resources/views/contact/index.blade.php @@ -185,7 +185,7 @@ }, { data: 'id', - name: 'customer.id' + name: 'contacts.id' }, { data: 'firstname', diff --git a/resources/views/emails/exception.blade.php b/resources/views/emails/exception.blade.php new file mode 100644 index 0000000..4eef74b --- /dev/null +++ b/resources/views/emails/exception.blade.php @@ -0,0 +1,18 @@ + + + + + + + Exception — {{ config('app.name') }} + + + + + {!! $content !!} + + + diff --git a/vendor.tar b/vendor.tar new file mode 100644 index 0000000..eba74bf Binary files /dev/null and b/vendor.tar differ