deplay phase 1

This commit is contained in:
Kevin Adametz 2026-04-17 17:19:11 +02:00
parent e3dc1afd8e
commit 5a7478907e
68 changed files with 2831 additions and 818 deletions

1
.env
View file

@ -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

View file

@ -37,7 +37,7 @@ class ContactsFindDuplicates extends Command
// ── HIGH: gleiche E-Mail ──────────────────────────────────────────
if ($this->shouldCheck('HIGH')) {
$emailDupes = DB::table('contacts')
$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', '!=', '')
@ -60,7 +60,7 @@ class ContactsFindDuplicates extends Command
// ── MEDIUM: Name + Vorname + Geburtsdatum ────────────────────────
if ($this->shouldCheck('MEDIUM')) {
$nameBdDupes = DB::table('contacts')
$nameBdDupes = DB::table('customer')
->select(
'name', 'firstname', 'birthdate',
DB::raw('COUNT(*) as cnt'),
@ -88,7 +88,7 @@ class ContactsFindDuplicates extends Command
// ── LOW: Name + Vorname + PLZ ─────────────────────────────────────
if ($this->shouldCheck('LOW')) {
$nameZipDupes = DB::table('contacts')
$nameZipDupes = DB::table('customer')
->select(
'name', 'firstname', 'zip',
DB::raw('COUNT(*) as cnt'),

View file

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

View file

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

View file

@ -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
);
}
}
}

View file

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

View file

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

View file

@ -49,7 +49,7 @@ class ReportLeadsController extends Controller
{
$query = Lead::with('customer')->with('sf_guard_user')->with('status')
->select('inquiries.*');
->select('lead.*');
//->where('deleted_at', '=', null);
/*
@ -132,7 +132,7 @@ class ReportLeadsController extends Controller
// $q->select('sent_at')->where('sent_at', DB::raw("(select max('sent_at') customer_mails)")); //)
})->orderBy(
LeadMail::select('sent_at')
->whereColumn('lead_id', 'inquiries.id')
->whereColumn('lead_id', 'lead.id')
->orderBy('sent_at', 'DESC')
->limit(1)
, $order);

View file

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

View file

@ -157,11 +157,11 @@ class ContactController extends Controller
}
DB::transaction(function () use ($masterId, $dupeId) {
DB::table('inquiries')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
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('contacts')->where('id', $dupeId)->update([
DB::table('customer')->where('id', $dupeId)->update([
'merged_into_id' => $masterId,
'merged_at' => now(),
]);
@ -175,16 +175,16 @@ class ContactController extends Controller
private function countDuplicateGroups(string $type): int
{
return match ($type) {
'email' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('email')->where('email', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('email')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_birthdate' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1')->get()->count(),
'name_zip' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('zip')->where('zip', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1')->get()->count(),
'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('contacts')
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')
@ -194,7 +194,7 @@ class ContactController extends Controller
private function findByNameBirthdate(): array
{
return DB::table('contacts')
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')
@ -204,7 +204,7 @@ class ContactController extends Controller
private function findByNameZip(): array
{
return DB::table('contacts')
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', '!=', '')
@ -228,8 +228,8 @@ class ContactController extends Controller
// Reihenfolge wichtig: select() zuerst, dann withCount() — sonst überschreibt
// select() die COUNT-Subqueries die withCount() per addSelect() eingetragen hat.
$query = $showDeleted
? Contact::onlyTrashed()->withoutGlobalScope('not_merged')->select('contacts.*')->withCount(['leads', 'bookings'])
: Contact::select('contacts.*')->withCount(['leads', 'bookings']);
? 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')) {
@ -275,7 +275,7 @@ class ContactController extends Controller
->orderColumn('deleted_at', 'customer.deleted_at $1')
->filterColumn('id', function ($query, $keyword) {
if ($keyword !== '') {
$query->where('contacts.id', 'LIKE', '%' . $keyword . '%');
$query->where('customer.id', 'LIKE', '%' . $keyword . '%');
}
})
->filterColumn('name', function ($query, $keyword) {

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
* @property int $id
* @property Carbon $booking_date
* @property int $customer_id
* @property int $inquiry_id
* @property int $lead_id
* @property bool $new_drafts
* @property int $sf_guard_user_id
* @property int $branch_id
@ -212,8 +212,7 @@ class Booking extends Model
protected $casts = [
'customer_id' => 'int',
'inquiry_id' => 'int',
'offer_id' => 'int',
'lead_id' => 'int',
'new_drafts' => 'bool',
'sf_guard_user_id' => 'int',
'branch_id' => 'int',
@ -257,8 +256,7 @@ class Booking extends Model
protected $fillable = [
'booking_date',
'customer_id',
'inquiry_id',
'offer_id',
'lead_id',
'new_drafts',
'sf_guard_user_id',
'branch_id',
@ -395,29 +393,9 @@ 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, '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);
return $this->belongsTo(Lead::class);
}
public function sf_guard_user()

View file

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

View file

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

View file

@ -112,12 +112,7 @@ class Lead extends Model
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';
protected $table = 'lead';
protected $casts = [
'customer_id' => 'int',

View file

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

View file

@ -63,13 +63,13 @@ class BookingPDFRepository extends BaseRepository
{
$document = new stdClass();
$document->name = 'registration';
$document->number = $this->model->inquiry_id;
$document->number = $this->model->lead_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->inquiry_id . ".pdf";
$filename = "Buchnungsauftrag-" . $this->model->lead_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->inquiry_id;
$document->number = $this->model->lead_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->inquiry_id . ".pdf";
$filename = "Reisebestätigung-" . $this->model->lead_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->inquiry_id;
$document->number = $this->model->lead_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->inquiry_id . ".pdf";
$filename = ($agency ? 'VoucherAgentur' : 'Voucher') . "-" . $this->model->lead_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->inquiry_id;
$document->number = $this->model->lead_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->inquiry_id . ".pdf";
$filename = "Reisestornierung -" . $this->model->lead_id . ".pdf";
$pdf_file = new CreatePDF('pdf.booking_storno');
$data = [
@ -288,9 +288,7 @@ class BookingPDFRepository extends BaseRepository
$fill = [
'booking_id' => $this->model->id,
'customer_id' => $this->model->customer_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,
'lead_id' => $this->model->lead_id,
'identifier' => $identifier,
'filename' => $filename,
'dir' => $dir,

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ return [
*/
'debug' => env('APP_DEBUG', false),
'exception_mail' => env('EXCEPTION_MAIL', 'exception@adametz.media'),
/*
|--------------------------------------------------------------------------

View file

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

View file

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

View file

@ -0,0 +1,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
```

View file

@ -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()

View file

@ -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',

View file

@ -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 @@
+<?php
+
+namespace App\Models;
+
+use App\Models\Sym\TravelCountry;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * Kontakt-Modell — saubere Neuimplementierung auf Basis der customer-Tabelle.
+ *
+ * Unterschiede zum alten Customer-Modell:
+ * - Global Scope schließt zusammengeführte Duplikate (merged_into_id IS NOT NULL) aus
+ * - merged_into_id + merged_at in $fillable
+ * - mergedInto() / mergedContacts() Beziehungen
+ *
+ * Tabellen-Name: 'customer' (wird in Phase 2 in 'contacts' umbenannt).
+ *
+ * @property int $id
+ * @property int|null $salutation_id
+ * @property string|null $title
+ * @property string|null $name
+ * @property string|null $firstname
+ * @property Carbon|null $birthdate
+ * @property string|null $company
+ * @property string|null $street
+ * @property string|null $zip
+ * @property string|null $city
+ * @property string|null $email
+ * @property string|null $phone
+ * @property string|null $phonebusiness
+ * @property string|null $phonemobile
+ * @property string|null $fax
+ * @property int|null $merged_into_id
+ * @property Carbon|null $merged_at
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ * @property-read Contact|null $mergedInto
+ * @property-read Collection|Contact[] $mergedContacts
+ * @property-read Collection|Lead[] $leads
+ * @property-read Collection|Booking[] $bookings
+ */
+class Contact extends Model
+{
+ use HasFactory, SoftDeletes;
+
+ protected $connection = 'mysql';
+
+ protected $table = 'customer';
+
+ protected $casts = [
+ 'salutation_id' => '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',
+ ];
+}

View file

@ -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 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Contact;
+use App\Repositories\ContactRepository;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Yajra\DataTables\Facades\DataTables;
+
+class ContactController extends Controller
+{
+ public function __construct(private readonly ContactRepository $contactRepo)
+ {
+ $this->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 '<a href="' . route('contact_detail', [$contact->id]) . '" class="btn icon-btn btn-sm btn-primary" title="Öffnen"><span class="fa fa-edit"></span></a>';
+ })
+ ->addColumn('action_delete', function (Contact $contact) use ($showDeleted) {
+ if ($showDeleted) {
+ return '<button class="btn icon-btn btn-xs btn-success ml-1 btn-contact-restore" '
+ . 'data-id="' . $contact->id . '" '
+ . 'data-name="' . e($contact->fullName()) . '" '
+ . 'title="Wiederherstellen"><span class="fa fa-undo"></span></button>';
+ }
+ return '<button class="btn icon-btn btn-xs btn-danger ml-1 btn-contact-delete" '
+ . 'data-id="' . $contact->id . '" '
+ . 'data-name="' . e($contact->fullName()) . '" '
+ . 'title="Löschen"><span class="fa fa-trash"></span></button>';
+ })
+ ->addColumn('id', function (Contact $contact) use ($showDeleted) {
+ if ($showDeleted) {
+ return '<span data-order="' . $contact->id . '">' . $contact->id . '</span>';
+ }
+ return '<a data-order="' . $contact->id . '" href="' . route('contact_detail', [$contact->id]) . '">' . $contact->id . '</a>';
+ })
+ ->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);
+ }
+}

View file

@ -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',

View file

@ -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',

View file

@ -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,

View file

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

View file

@ -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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Phase 1 — Schritt 1: Duplikate identifizieren
+ *
+ * Sucht Kunden-Datensätze, die vermutlich dieselbe Person repräsentieren.
+ * Drei Erkennungs-Stufen (absteigend nach Konfidenz):
+ *
+ * HIGH — gleiche E-Mail (nicht leer)
+ * MEDIUM — gleicher Name + Vorname + Geburtsdatum
+ * LOW — gleicher Name + Vorname + PLZ
+ *
+ * Verwendung:
+ * php artisan contacts:find-duplicates
+ * php artisan contacts:find-duplicates --export=duplicates.csv
+ * php artisan contacts:find-duplicates --confidence=HIGH
+ */
+class ContactsFindDuplicates extends Command
+{
+ protected $signature = 'contacts:find-duplicates
+ {--export= : Pfad zur CSV-Ausgabedatei}
+ {--confidence= : Nur diese Konfidenz-Stufe ausgeben (HIGH|MEDIUM|LOW)}';
+
+ protected $description = 'Identifiziert doppelte Customer-Datensätze anhand von E-Mail, Name/Geburtsdatum oder Name/PLZ';
+
+ public function handle(): int
+ {
+ $this->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('<fg=green>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('<fg=yellow>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('<fg=red>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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Phase 1 — Schritt 2: Duplikate zusammenführen
+ *
+ * Strategie: Der neueste Datensatz (höchstes updated_at, dann höchste id)
+ * wird Master. Alle anderen Datensätze derselben Gruppe erhalten
+ * merged_into_id = master_id und werden nicht mehr zurückgegeben.
+ *
+ * Alle FK-Referenzen in lead, booking, customer_mails, lead_mails
+ * werden auf den Master umgestellt.
+ *
+ * Verwendung:
+ * php artisan contacts:merge-duplicates --dry-run # Vorschau, keine Änderung
+ * php artisan contacts:merge-duplicates --confidence=HIGH # Nur sichere Duplikate
+ * php artisan contacts:merge-duplicates # Ausführen
+ */
+class ContactsMergeDuplicates extends Command
+{
+ protected $signature = 'contacts:merge-duplicates
+ {--dry-run : Zeigt was passieren würde, ohne Daten zu ändern}
+ {--confidence= : Nur diese Konfidenz-Stufe verarbeiten (HIGH|MEDIUM|LOW)}
+ {--force : Überspringt Sicherheitsabfrage}';
+
+ protected $description = 'Führt doppelte Customer-Datensätze zusammen (neuester wird Master)';
+
+ private bool $dryRun = false;
+ private int $mergedCount = 0;
+ private int $updatedLeads = 0;
+ private int $updatedBookings = 0;
+
+ public function handle(): int
+ {
+ $this->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++;
+ }
+}

View file

@ -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'),

View file

@ -0,0 +1,128 @@
#!/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} -- <datei>"
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):"
grep -rn "inquiry_id" app/ 2>/dev/null | head -5 || echo " (keine gefunden — Restore eventuell nicht vollständig)"
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

View file

@ -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 1530 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 24)
- `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

View file

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

View file

@ -0,0 +1,18 @@
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exception {{ config('app.name') }}</title>
<style type="text/css">
{!! $css !!}
</style>
</head>
<body>
{!! $content !!}
</body>
</html>

View file

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

View file

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

BIN
vendor.tar Normal file

Binary file not shown.