Compare commits
2 commits
e3dc1afd8e
...
ba48745809
| Author | SHA1 | Date | |
|---|---|---|---|
| ba48745809 | |||
| 5a7478907e |
59 changed files with 4618 additions and 1907 deletions
1
.env
1
.env
|
|
@ -16,6 +16,7 @@ APP_DOMAIN_TLD=test
|
|||
LOG_CHANNEL=stack
|
||||
|
||||
SUCCESS_KEY=f6077389c9ce710e554763a5de02c8ec
|
||||
EXCEPTION_MAIL=exception@adametz.media
|
||||
|
||||
# Standard Database Connection
|
||||
DB_CONNECTION=mysql
|
||||
|
|
|
|||
156
app/Console/Commands/BookingsAuditStornoPriceTotal.php
Normal file
156
app/Console/Commands/BookingsAuditStornoPriceTotal.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Findet Buchungen, bei denen nach Storno price_total nicht mehr zu price_canceled passt
|
||||
* (typisch nach Speichern / calculate_price_total ohne Storno-Schutz).
|
||||
*
|
||||
* php artisan bookings:audit-storno-price-total
|
||||
* php artisan bookings:audit-storno-price-total --export=/tmp/mismatch.csv
|
||||
* php artisan bookings:audit-storno-price-total --fix (setzt price_total = price_canceled)
|
||||
*/
|
||||
class BookingsAuditStornoPriceTotal extends Command
|
||||
{
|
||||
protected $signature = 'bookings:audit-storno-price-total
|
||||
{--fix : Setzt price_total auf price_canceled für betroffene Zeilen}
|
||||
{--export= : CSV-Datei (relativ zum Projektroot oder absolut)}
|
||||
{--id=* : Nur diese Buchungs-IDs (booking.id)}';
|
||||
|
||||
protected $description = 'Abgleich Storno: price_total vs. price_canceled (inkl. optional --fix / --export)';
|
||||
|
||||
private const EPS = 0.009;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$query = Booking::query()
|
||||
->whereNotNull('canceled')
|
||||
->whereNotNull('price_canceled')
|
||||
->orderBy('id');
|
||||
|
||||
$ids = $this->option('id');
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$mismatch = [];
|
||||
$fixable = [];
|
||||
|
||||
foreach ($query->cursor() as $booking) {
|
||||
$pt = (float) $booking->getPriceTotalRaw();
|
||||
$pc = (float) $booking->getPriceCanceledRaw();
|
||||
if (abs($pt - $pc) <= self::EPS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasStornoRow = $booking->booking_strono()->exists();
|
||||
$hasStornoDoc = $booking->hasDocument('storno');
|
||||
|
||||
$mismatch[] = [
|
||||
'id' => $booking->id,
|
||||
'merlin_order_number' => $booking->merlin_order_number,
|
||||
'price' => $booking->getPriceRaw(),
|
||||
'price_canceled' => $pc,
|
||||
'price_total_db' => $pt,
|
||||
'price_balance' => $booking->getPriceBalanceRaw(),
|
||||
'canceled_pct' => $booking->getCanceledRaw(),
|
||||
'booking_storno_row' => $hasStornoRow ? 'ja' : 'nein',
|
||||
'storno_pdf' => $hasStornoDoc ? 'ja' : 'nein',
|
||||
'diff' => round($pt - $pc, 2),
|
||||
];
|
||||
$fixable[] = $booking->id;
|
||||
}
|
||||
|
||||
$this->warn('Storno mit gesetztem price_canceled, aber price_total weicht ab:');
|
||||
$this->newLine();
|
||||
|
||||
if ($mismatch === []) {
|
||||
$this->info('Keine Abweichungen gefunden.');
|
||||
} else {
|
||||
$this->table(
|
||||
['id', 'MyJack', 'price', 'price_canceled', 'price_total', 'diff', 'storno_row', 'storno_pdf'],
|
||||
collect($mismatch)->map(function ($r) {
|
||||
return [
|
||||
$r['id'],
|
||||
$r['merlin_order_number'],
|
||||
$r['price'],
|
||||
$r['price_canceled'],
|
||||
$r['price_total_db'],
|
||||
$r['diff'],
|
||||
$r['booking_storno_row'],
|
||||
$r['storno_pdf'],
|
||||
];
|
||||
})->all()
|
||||
);
|
||||
$this->newLine();
|
||||
$this->line('Anzahl: '.count($mismatch));
|
||||
}
|
||||
|
||||
$exportPath = $this->option('export');
|
||||
if ($exportPath && $mismatch !== []) {
|
||||
$path = $this->resolveExportPath($exportPath);
|
||||
$fp = fopen($path, 'w');
|
||||
if ($fp === false) {
|
||||
$this->error('Export nicht schreibbar: '.$path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
fputcsv($fp, array_keys($mismatch[0]), ';');
|
||||
foreach ($mismatch as $row) {
|
||||
fputcsv($fp, $row, ';');
|
||||
}
|
||||
fclose($fp);
|
||||
$this->info('CSV geschrieben: '.$path);
|
||||
}
|
||||
|
||||
if ($this->option('fix')) {
|
||||
if ($fixable === []) {
|
||||
$this->info('Nichts zu korrigieren.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
if (! $this->confirm('price_total für '.count($fixable).' Buchung(en) auf price_canceled setzen?', true)) {
|
||||
$this->warn('Abgebrochen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
DB::transaction(function () use ($fixable, &$updated) {
|
||||
foreach ($fixable as $id) {
|
||||
$b = Booking::query()->lockForUpdate()->find($id);
|
||||
if (! $b) {
|
||||
continue;
|
||||
}
|
||||
$pc = (float) $b->getPriceCanceledRaw();
|
||||
$pt = (float) $b->getPriceTotalRaw();
|
||||
if (abs($pt - $pc) <= self::EPS) {
|
||||
continue;
|
||||
}
|
||||
$b->price_total = round($pc, 2);
|
||||
$b->save();
|
||||
$updated++;
|
||||
}
|
||||
});
|
||||
$this->info("Korrigiert: {$updated} Buchung(en).");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Hinweis: Buchungen mit canceled gesetzt, aber price_canceled NULL, werden hier nicht geprüft.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveExportPath(string $exportPath): string
|
||||
{
|
||||
if ($exportPath[0] === '/' || preg_match('#^[A-Za-z]:\\\\#', $exportPath)) {
|
||||
return $exportPath;
|
||||
}
|
||||
|
||||
return base_path(trim($exportPath, '/'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,10 +172,10 @@ class ReportBookingController extends Controller
|
|||
->orderColumn('end_date', 'end_date $1')
|
||||
->orderColumn('price', 'price $1')
|
||||
->orderColumn('booking_date', 'booking_date $1')
|
||||
->orderColumn('customer.fullName', 'customer.firstname $1')
|
||||
->orderColumn('customer.firstname', 'customer.firstname $1')
|
||||
->orderColumn('customer.name', 'customer.name $1')
|
||||
//->orderColumn('lead.status_id', 'lead.status_id $1')
|
||||
->orderColumn('customer.fullName', 'contacts.firstname $1')
|
||||
->orderColumn('customer.firstname', 'contacts.firstname $1')
|
||||
->orderColumn('customer.name', 'contacts.name $1')
|
||||
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
|
||||
//->orderColumn('is_cleared', 'is_cleared $1')
|
||||
->rawColumns(['id', 'lead.status_id', 'service_provider.names'])
|
||||
->make(true);
|
||||
|
|
@ -384,9 +384,9 @@ class ReportBookingController extends Controller
|
|||
->orderColumn('end_date', 'end_date $1')
|
||||
->orderColumn('price', 'price $1')
|
||||
->orderColumn('booking_date', 'booking_date $1')
|
||||
->orderColumn('customer.firstname', 'customer.firstname $1')
|
||||
->orderColumn('customer.name', 'customer.name $1')
|
||||
//->orderColumn('lead.status_id', 'lead.status_id $1')
|
||||
->orderColumn('customer.firstname', 'contacts.firstname $1')
|
||||
->orderColumn('customer.name', 'contacts.name $1')
|
||||
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
|
||||
//->orderColumn('is_cleared', 'is_cleared $1')
|
||||
->rawColumns(['id', 'old_crm', 'check_total', 'lead.status_id'])
|
||||
->make(true);
|
||||
|
|
|
|||
|
|
@ -189,10 +189,10 @@ class ReportController extends Controller
|
|||
->orderColumn('end_date', 'end_date $1')
|
||||
->orderColumn('price', 'price $1')
|
||||
->orderColumn('booking_date', 'booking_date $1')
|
||||
->orderColumn('customer.fullName', 'customer.firstname $1')
|
||||
->orderColumn('customer.firstname', 'customer.firstname $1')
|
||||
->orderColumn('customer.name', 'customer.name $1')
|
||||
//->orderColumn('lead.status_id', 'lead.status_id $1')
|
||||
->orderColumn('customer.fullName', 'contacts.firstname $1')
|
||||
->orderColumn('customer.firstname', 'contacts.firstname $1')
|
||||
->orderColumn('customer.name', 'contacts.name $1')
|
||||
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
|
||||
//->orderColumn('is_cleared', 'is_cleared $1')
|
||||
->rawColumns(['id', 'lead.status_id', 'service_provider.names'])
|
||||
->make(true);
|
||||
|
|
@ -725,9 +725,9 @@ class ReportController extends Controller
|
|||
->orderColumn('end_date', 'end_date $1')
|
||||
->orderColumn('price', 'price $1')
|
||||
->orderColumn('booking_date', 'booking_date $1')
|
||||
->orderColumn('customer.firstname', 'customer.firstname $1')
|
||||
->orderColumn('customer.name', 'customer.name $1')
|
||||
//->orderColumn('lead.status_id', 'lead.status_id $1')
|
||||
->orderColumn('customer.firstname', 'contacts.firstname $1')
|
||||
->orderColumn('customer.name', 'contacts.name $1')
|
||||
//->orderColumn('lead.status_id', 'inquiries.status_id $1')
|
||||
//->orderColumn('is_cleared', 'is_cleared $1')
|
||||
->rawColumns(['id', 'old_crm', 'check_total', 'lead.status_id'])
|
||||
->make(true);
|
||||
|
|
|
|||
|
|
@ -161,9 +161,9 @@ class ReportLeadsController extends Controller
|
|||
$orderByNum = [
|
||||
0 => 'id',
|
||||
1 => 'customer_id',
|
||||
2 => 'customer.firstname',
|
||||
3 => 'customer.name',
|
||||
4 => 'customer.email',
|
||||
2 => 'contacts.firstname',
|
||||
3 => 'contacts.name',
|
||||
4 => 'contacts.email',
|
||||
5 => 'request_date',
|
||||
6 => 'travel_country',
|
||||
7 => 'sf_guard_user.last_name',
|
||||
|
|
|
|||
|
|
@ -271,9 +271,9 @@ class ContactController extends Controller
|
|||
->addColumn('leads_count', fn(Contact $contact) => $contact->leads_count)
|
||||
->addColumn('bookings_count', fn(Contact $contact) => $contact->bookings_count)
|
||||
->addColumn('deleted_at', fn(Contact $contact) => $contact->deleted_at?->format('d.m.Y H:i') ?? '')
|
||||
->orderColumn('id', 'customer.id $1')
|
||||
->orderColumn('deleted_at', 'customer.deleted_at $1')
|
||||
->filterColumn('id', function ($query, $keyword) {
|
||||
->orderColumn('contacts.id', 'contacts.id $1')
|
||||
->orderColumn('deleted_at', 'contacts.deleted_at $1')
|
||||
->filterColumn('contacts.id', function ($query, $keyword) {
|
||||
if ($keyword !== '') {
|
||||
$query->where('contacts.id', 'LIKE', '%' . $keyword . '%');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -787,8 +787,6 @@ class Booking extends Model
|
|||
$total_children += $prices['children'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($travel_draft_item) {
|
||||
$travel_draft_item->setPriceAdultRaw($travel_price_adult);
|
||||
$travel_draft_item->setPriceChildrenRaw($travel_price_children);
|
||||
|
|
@ -797,10 +795,23 @@ class Booking extends Model
|
|||
$travel_draft_item->save();
|
||||
}
|
||||
$this->price = $total_adult + $total_children;
|
||||
$this->price_total = $this->getPriceRaw() + $this->getServiceTotal(true);
|
||||
$this->setPriceTotalForCurrentState();
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesamtpreis Reise (price_total): bei Storno mit gesetztem Storno-Betrag = price_canceled
|
||||
* (wie nach createPDF_Storno in BookingPDFRepository), sonst Reisepreis + Vermittlung.
|
||||
*/
|
||||
public function setPriceTotalForCurrentState(): void
|
||||
{
|
||||
if ($this->isCanceled() && $this->attributes['price_canceled'] !== null) {
|
||||
$this->price_total = round((float) $this->getPriceCanceledRaw(), 2);
|
||||
return;
|
||||
}
|
||||
$this->price_total = round((float) $this->getPriceRaw() + (float) $this->getServiceTotal(true), 2);
|
||||
}
|
||||
|
||||
public function getPriceAttribute()
|
||||
{
|
||||
return Util::_number_format($this->attributes['price']);
|
||||
|
|
|
|||
|
|
@ -172,20 +172,20 @@ class Lead extends Model
|
|||
protected $passolutionPDFs = [];
|
||||
|
||||
public static $lead_mail_dirs = [
|
||||
11 => ['name' => 'Entwürfe', 'icon'=>'ion-md-create'],
|
||||
12 => ['name' => 'Papierkorb', 'icon'=>'ion-md-trash'],
|
||||
11 => ['name' => 'Entwürfe', 'icon' => 'ion-md-create'],
|
||||
12 => ['name' => 'Papierkorb', 'icon' => 'ion-md-trash'],
|
||||
];
|
||||
|
||||
public function updateNextDueDate($date = false){
|
||||
public function updateNextDueDate($date = false)
|
||||
{
|
||||
|
||||
if(!$date){
|
||||
if (!$date) {
|
||||
$carbon = Carbon::now();
|
||||
}else{
|
||||
} else {
|
||||
$carbon = Carbon::parse($date);
|
||||
}
|
||||
$this->next_due_date = $carbon->modify('+ '.$this->status->handling_days.' days')->format("Y-m-d");
|
||||
$this->next_due_date = $carbon->modify('+ ' . $this->status->handling_days . ' days')->format("Y-m-d");
|
||||
$this->save();
|
||||
|
||||
}
|
||||
public function customer()
|
||||
{
|
||||
|
|
@ -248,7 +248,8 @@ class Lead extends Model
|
|||
|
||||
public function bookings()
|
||||
{
|
||||
return $this->hasMany(Booking::class);
|
||||
// Modul 3 Phase 2: FK heißt jetzt inquiry_id (vormals lead_id)
|
||||
return $this->hasMany(Booking::class, 'inquiry_id');
|
||||
}
|
||||
|
||||
public function inquiries()
|
||||
|
|
@ -298,28 +299,31 @@ class Lead extends Model
|
|||
}
|
||||
|
||||
|
||||
public static function getSfGuardUserArray(){
|
||||
public static function getSfGuardUserArray()
|
||||
{
|
||||
return SfGuardUser::where('is_active', 1)->get()->pluck('fullname', 'id');
|
||||
}
|
||||
|
||||
public static function getTravelCountryArray($emtpy = false){
|
||||
public static function getTravelCountryArray($emtpy = false)
|
||||
{
|
||||
$TravelCountry = TravelCountry::where('active_backend', 1)->orderBy('name')->get()->pluck('name', 'id');
|
||||
return $emtpy ? $TravelCountry->prepend('-', 0) : $TravelCountry;
|
||||
|
||||
}
|
||||
|
||||
public static function getTravelCategoryArray($emtpy = false){
|
||||
public static function getTravelCategoryArray($emtpy = false)
|
||||
{
|
||||
$TravelCategory = TravelCategory::orderBy('name')->get()->pluck('name', 'id');
|
||||
return $emtpy ? $TravelCategory->prepend('-', 0) : $TravelCategory;
|
||||
|
||||
}
|
||||
|
||||
public static function getTravelAgendaArray($emtpy = false){
|
||||
public static function getTravelAgendaArray($emtpy = false)
|
||||
{
|
||||
$TravelAgenda = TravelAgenda::orderBy('name')->get()->pluck('name', 'id');
|
||||
return $emtpy ? $TravelAgenda->prepend('-', 0) : $TravelAgenda;
|
||||
}
|
||||
|
||||
public static function getStatusArray($emtpy = false){
|
||||
public static function getStatusArray($emtpy = false)
|
||||
{
|
||||
$Status = Status::orderBy('name')->get()->pluck('name', 'id');
|
||||
return $emtpy ? $Status->prepend('-', 0) : $Status;
|
||||
}
|
||||
|
|
@ -327,65 +331,68 @@ class Lead extends Model
|
|||
|
||||
public function getStatusBadge($booking = null)
|
||||
{
|
||||
if($this->status_id && $this->status){
|
||||
if ($this->status_id && $this->status) {
|
||||
$color = $this->status->color;
|
||||
$icon = "";
|
||||
if($this->status_id == 14 && $this->is_rebook){
|
||||
if ($this->status_id == 14 && $this->is_rebook) {
|
||||
$color = '#94ae59';
|
||||
$icon = '<i class="fa fa-check-circle"></i> ';
|
||||
}
|
||||
if($this->status_id == 14 && !$this->is_rebook){
|
||||
if ($this->status_id == 14 && !$this->is_rebook) {
|
||||
$icon = '<i class="fa fa-times-circle"></i> ';
|
||||
}
|
||||
if($this->status_id == 15){
|
||||
if ($this->status_id == 15) {
|
||||
$icon = '<i class="fa fa-balance-scale"></i> ';
|
||||
if($booking && $booking->lawyer_date){
|
||||
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$booking->lawyer_date->format('d.m.Y').'</span></span>';
|
||||
if ($booking && $booking->lawyer_date) {
|
||||
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $booking->lawyer_date->format('d.m.Y') . '</span></span>';
|
||||
}
|
||||
}
|
||||
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$this->status->name.'</span></span>';
|
||||
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $this->status->name . '</span></span>';
|
||||
}
|
||||
return '<span data-order="0">-</span>';
|
||||
}
|
||||
|
||||
public function getTravelCountryDestco($badge = true){
|
||||
public function getTravelCountryDestco($badge = true)
|
||||
{
|
||||
|
||||
$out = "";
|
||||
if($this->bookings->count()){
|
||||
if ($this->bookings->count()) {
|
||||
$out .= $badge ? '<span class="badge badge-success">' : '';
|
||||
foreach ($this->bookings as $booking){
|
||||
if($booking->travel_country_id && $booking->travel_country) {
|
||||
foreach ($this->bookings as $booking) {
|
||||
if ($booking->travel_country_id && $booking->travel_country) {
|
||||
$out .= $booking->travel_country->destco;
|
||||
}
|
||||
}
|
||||
$out .= $badge ? '</span>' : '';
|
||||
return $out;
|
||||
}
|
||||
if($this->travel_country){
|
||||
return $badge ? '<span class="badge badge-secondary">'.$this->travel_country->destco.'</span>' : $this->travel_country->destco;
|
||||
if ($this->travel_country) {
|
||||
return $badge ? '<span class="badge badge-secondary">' . $this->travel_country->destco . '</span>' : $this->travel_country->destco;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
public function countLeadMailsBy($dir, $subdir=false){
|
||||
if($dir === 11){
|
||||
public function countLeadMailsBy($dir, $subdir = false)
|
||||
{
|
||||
if ($dir === 11) {
|
||||
return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count();
|
||||
}
|
||||
if($subdir){
|
||||
if ($subdir) {
|
||||
return $this->lead_mails->where('dir', $dir)->where('subdir', $subdir)->count();
|
||||
}
|
||||
return $this->lead_mails->where('dir', $dir)->count();
|
||||
}
|
||||
|
||||
public function getPassolutionPDF($create = false, $resync = false){
|
||||
public function getPassolutionPDF($create = false, $resync = false)
|
||||
{
|
||||
|
||||
$nats = [];
|
||||
|
||||
if(count($this->passolutionPDFs)){
|
||||
if (count($this->passolutionPDFs)) {
|
||||
return $this->passolutionPDFs;
|
||||
}
|
||||
|
||||
if(!$this->travel_country){
|
||||
if (!$this->travel_country) {
|
||||
return $this->passolutionPDFs;
|
||||
}
|
||||
|
||||
|
|
@ -393,17 +400,17 @@ class Lead extends Model
|
|||
//default no travel_nationality
|
||||
$nats['de'] = 'de';
|
||||
|
||||
if($this->lead_participants->count()){
|
||||
foreach ($this->lead_participants as $participant){
|
||||
if($participant->travel_nationality){
|
||||
if ($this->lead_participants->count()) {
|
||||
foreach ($this->lead_participants as $participant) {
|
||||
if ($participant->travel_nationality) {
|
||||
$nats[$participant->travel_nationality->nat] = $participant->travel_nationality->nat;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(empty($nats)){
|
||||
if (empty($nats)) {
|
||||
$nats['de'] = 'de';
|
||||
}
|
||||
foreach ($nats as $nat){
|
||||
foreach ($nats as $nat) {
|
||||
$data = [
|
||||
'nat' => $nat,
|
||||
'destco' => $destco,
|
||||
|
|
@ -414,7 +421,8 @@ class Lead extends Model
|
|||
return $this->passolutionPDFs;
|
||||
}
|
||||
|
||||
public function resyncPassolutionPDF(){
|
||||
public function resyncPassolutionPDF()
|
||||
{
|
||||
return $this->getPassolutionPDF(true, true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,11 +157,12 @@ class BookingRepository extends BaseRepository
|
|||
{
|
||||
|
||||
$this->model = Booking::findOrFail($id);
|
||||
$this->model->setPriceTotalForCurrentState();
|
||||
$fill = [
|
||||
'deposit_total' => $data['deposit_total'] ? Util::_clean_float($data['deposit_total']) : 0,
|
||||
'final_payment' => $data['final_payment'] ? Util::_clean_float($data['final_payment']) : 0,
|
||||
'final_payment_date' => $data['final_payment_date'] ? _reformat_date($data['final_payment_date']) : null,
|
||||
'price_total' => ($this->model->getPriceRaw() + $this->model->getServiceTotal(true)),
|
||||
'price_total' => $this->model->getPriceTotalRaw(),
|
||||
];
|
||||
$this->model->fill($fill);
|
||||
$this->model->save();
|
||||
|
|
@ -210,7 +211,7 @@ class BookingRepository extends BaseRepository
|
|||
}
|
||||
}
|
||||
}
|
||||
$this->model->price_total = ($this->model->getPriceRaw() + $this->model->getServiceTotal(true));
|
||||
$this->model->setPriceTotalForCurrentState();
|
||||
$this->model->save();
|
||||
|
||||
return $this->model;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class Booking
|
|||
->pluck('slug', 'id');
|
||||
}
|
||||
|
||||
public static function setOutputDirs(string $dir, string $subdir): void
|
||||
public static function setOutputDirs(string $dir, ?string $subdir): void
|
||||
{
|
||||
MailDirService::setOutputDir($dir, $subdir);
|
||||
}
|
||||
|
|
@ -39,12 +39,12 @@ class Booking
|
|||
return MailDirService::getCustomerMailDir($id);
|
||||
}
|
||||
|
||||
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, int $mailDirId): string
|
||||
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, ?int $mailDirId): string
|
||||
{
|
||||
return MailDirService::getCustomerMailName($mailDir, $mailDirId);
|
||||
}
|
||||
|
||||
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, int $mailDirId): array|string
|
||||
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, ?int $mailDirId): array|string
|
||||
{
|
||||
return MailDirService::getCustomerMailEmails($mailDir, $mailDirId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class Lead
|
|||
->pluck('slug', 'id');
|
||||
}
|
||||
|
||||
public static function setOutputDirs(string $dir, string $subdir): void
|
||||
public static function setOutputDirs(string $dir, ?string $subdir): void
|
||||
{
|
||||
MailDirService::setOutputDir($dir, $subdir);
|
||||
}
|
||||
|
|
@ -39,12 +39,12 @@ class Lead
|
|||
return MailDirService::getCustomerMailDir($id);
|
||||
}
|
||||
|
||||
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, int $mailDirId): string
|
||||
public static function getCustomerMailName(\App\Models\CMSContent $mailDir, ?int $mailDirId): string
|
||||
{
|
||||
return MailDirService::getCustomerMailName($mailDir, $mailDirId);
|
||||
}
|
||||
|
||||
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, int $mailDirId): array|string
|
||||
public static function getCustomerMailEmails(\App\Models\CMSContent $mailDir, ?int $mailDirId): array|string
|
||||
{
|
||||
return MailDirService::getCustomerMailEmails($mailDir, $mailDirId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class MailDirService
|
|||
{
|
||||
private static array $outputDirs = [];
|
||||
|
||||
public static function setOutputDir(string $dir, string $subdir): void
|
||||
public static function setOutputDir(string $dir, ?string $subdir): void
|
||||
{
|
||||
self::$outputDirs[$dir][] = $subdir;
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ class MailDirService
|
|||
return CMSContent::where('identifier', '=', 'customer-mail-dirs')->where('pos', '=', $id)->first();
|
||||
}
|
||||
|
||||
public static function getCustomerMailName(CMSContent $mailDir, int $mailDirId): string
|
||||
public static function getCustomerMailName(CMSContent $mailDir, ?int $mailDirId): string
|
||||
{
|
||||
$model = self::resolveModel($mailDir, $mailDirId);
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ class MailDirService
|
|||
/**
|
||||
* @return array<string>|string
|
||||
*/
|
||||
public static function getCustomerMailEmails(CMSContent $mailDir, int $mailDirId): array|string
|
||||
public static function getCustomerMailEmails(CMSContent $mailDir, ?int $mailDirId): array|string
|
||||
{
|
||||
$model = self::resolveModel($mailDir, $mailDirId);
|
||||
|
||||
|
|
@ -79,8 +79,11 @@ class MailDirService
|
|||
return $result;
|
||||
}
|
||||
|
||||
private static function resolveModel(CMSContent $mailDir, int $mailDirId): mixed
|
||||
private static function resolveModel(CMSContent $mailDir, ?int $mailDirId): mixed
|
||||
{
|
||||
if ($mailDirId === null) {
|
||||
return null;
|
||||
}
|
||||
return match ($mailDir->getArrayContent('model')) {
|
||||
'TravelCountry' => \App\Models\Sym\TravelCountry::find($mailDirId),
|
||||
'Airline' => Airline::find($mailDirId),
|
||||
|
|
|
|||
1729
bootstrap/cache/config.php
vendored
1729
bootstrap/cache/config.php
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,7 @@ return [
|
|||
*/
|
||||
|
||||
'debug' => env('APP_DEBUG', false),
|
||||
'exception_mail' => env('EXCEPTION_MAIL', 'exception@adametz.media'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 1: customer → contacts
|
||||
*
|
||||
* Benennt die Tabelle um. Alle Foreign Keys, die auf `customer` zeigen,
|
||||
* werden von MySQL automatisch mitgezogen (ON DELETE / ON UPDATE bleiben).
|
||||
*
|
||||
* Rollback: benennt `contacts` zurück in `customer`.
|
||||
*
|
||||
* HINWEIS: Vor dem Deployment sicherstellen, dass die App-Models
|
||||
* bereits $table = 'contacts' verwenden, ODER die Migration vor dem
|
||||
* Code-Deployment ausführen und im Notfall zurückrollen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// MySQL: RENAME TABLE ist ein atomarer DDL-Befehl
|
||||
DB::statement('RENAME TABLE `customer` TO `contacts`');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `contacts` TO `customer`');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 2: lead → inquiries
|
||||
*
|
||||
* Benennt die Tabelle um. Abhängige Tabellen:
|
||||
* - lead_mails (lead_id FK → wird mitgezogen)
|
||||
* - lead_files (lead_id FK → wird mitgezogen)
|
||||
* - lead_notices (lead_id FK → wird mitgezogen)
|
||||
* - lead_participant (lead_id FK → wird mitgezogen)
|
||||
* - booking (lead_id FK → wird mitgezogen)
|
||||
* - customer_mails (lead_id FK → wird mitgezogen)
|
||||
*
|
||||
* Rollback: benennt `inquiries` zurück in `lead`.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `lead` TO `inquiries`');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `inquiries` TO `lead`');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 3: booking.lead_id → booking.inquiry_id
|
||||
*
|
||||
* Benennt die Spalte und den Foreign Key um.
|
||||
* inquiry_id bleibt nullable (Direktbuchungen ohne Anfrage sind erlaubt).
|
||||
*
|
||||
* Rollback: benennt inquiry_id zurück in lead_id.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Foreign Key muss zuerst gedroppt werden, bevor die Spalte umbenannt wird
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
// Drop existing FK (Name aus DB-Schema)
|
||||
$table->dropForeign('booking_lead_id_lead_id');
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE `booking` RENAME COLUMN `lead_id` TO `inquiry_id`');
|
||||
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
// Neuen FK auf umbenannte Tabelle setzen
|
||||
$table->foreign('inquiry_id', 'booking_inquiry_id_inquiries_id')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('no action')
|
||||
->onUpdate('no action');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
$table->dropForeign('booking_inquiry_id_inquiries_id');
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE `booking` RENAME COLUMN `inquiry_id` TO `lead_id`');
|
||||
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
$table->foreign('lead_id', 'booking_lead_id_lead_id')
|
||||
->references('id')->on('lead')
|
||||
->onDelete('no action')
|
||||
->onUpdate('no action');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — Schritt 1: Neue participants-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_participant + participant in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 3 Schritt 2
|
||||
* nach Abschluss der Tests gedroppt).
|
||||
*
|
||||
* Neue Felder:
|
||||
* - inquiry_id : FK auf inquiries (war: lead_participant.lead_id)
|
||||
* - booking_id : FK auf booking (war: participant.booking_id)
|
||||
* - participant_pass : nur bei Buchungs-Teilnehmern relevant
|
||||
* - participant_storno: nur bei Buchungs-Teilnehmern relevant
|
||||
* - is_lead_contact : markiert den Hauptkontakt aus lead.participant_name
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('participants_unified', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — genau einer ist gesetzt, der andere NULL
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Teilnehmerdaten
|
||||
$table->string('participant_name')->nullable();
|
||||
$table->string('participant_firstname')->nullable();
|
||||
$table->date('participant_birthdate')->nullable();
|
||||
$table->unsignedBigInteger('participant_salutation_id')->nullable();
|
||||
$table->boolean('participant_child')->default(false);
|
||||
$table->integer('nationality_id')->nullable();
|
||||
|
||||
// Nur bei Buchungs-Teilnehmern
|
||||
$table->boolean('participant_pass')->default(false);
|
||||
$table->boolean('participant_storno')->default(false);
|
||||
|
||||
// Markiert den Hauptreisenden aus lead.participant_name
|
||||
$table->boolean('is_lead_contact')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
|
||||
$table->foreign('inquiry_id', 'pu_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'pu_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('participant_salutation_id', 'pu_salutation_id_fk')
|
||||
->references('id')->on('salutation')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_participant → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, 0,
|
||||
NOW(), NOW()
|
||||
FROM lead_participant
|
||||
");
|
||||
|
||||
// 2. Aus lead.participant_name → Hauptreisende-Datensätze (is_lead_contact = 1)
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, 0,
|
||||
NULL, 1,
|
||||
NOW(), NOW()
|
||||
FROM inquiries
|
||||
WHERE participant_name IS NOT NULL
|
||||
AND participant_name != ''
|
||||
");
|
||||
|
||||
// 3. Aus participant → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, participant_pass, participant_storno,
|
||||
is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, participant_pass, participant_storno,
|
||||
0, NOW(), NOW()
|
||||
FROM participant
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('participants_unified');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 3 — Schritt 2: Alte Participant-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 3 Schritt 1 (participants_unified) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf participants_unified umgestellt
|
||||
* 3. Datenmigration durch Vergleich der Zeilenzahlen geprüft:
|
||||
* SELECT COUNT(*) FROM lead_participant;
|
||||
* SELECT COUNT(*) FROM participant;
|
||||
* SELECT COUNT(*) FROM participants_unified WHERE inquiry_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM participants_unified WHERE booking_id IS NOT NULL;
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup — erst ausführen wenn sicher!
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// FKs in lead_participant zeigen auf lead (jetzt inquiries) — werden mitgedroppt
|
||||
Schema::dropIfExists('lead_participant');
|
||||
|
||||
// FKs in participant zeigen auf booking — werden mitgedroppt
|
||||
Schema::dropIfExists('participant');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Bewusst leer: das Droppen von Produktionsdaten ist irreversibel.
|
||||
// Für einen vollständigen Rollback bitte Datenbank-Backup einspielen.
|
||||
throw new \RuntimeException(
|
||||
'Phase 3 Schritt 2 kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1a: communications-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_mails + customer_mails in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
|
||||
* (lead → inquiries, customer → contacts).
|
||||
*
|
||||
* Besonderheiten:
|
||||
* - reply_id ist selbst-referenziell; nach beiden INSERTs wird ein Remapping
|
||||
* der alten IDs auf die neuen IDs durchgeführt (via legacy_source + legacy_id).
|
||||
* - travel_country_id existiert nur in customer_mails → nullable, NULL für lead_mails.
|
||||
* - customer_mails hat sowohl lead_id als auch booking_id → beide werden übernommen.
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('communications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — bei customer_mails können beide gesetzt sein
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
$table->unsignedBigInteger('contact_id')->nullable();
|
||||
|
||||
// Reply-Chain — kein FK-Constraint (cross-table Remapping)
|
||||
$table->unsignedBigInteger('reply_id')->nullable();
|
||||
|
||||
// Herkunft für Remapping und spätere Analyse
|
||||
$table->enum('legacy_source', ['lead_mail', 'customer_mail']);
|
||||
$table->unsignedBigInteger('legacy_id');
|
||||
|
||||
// Mail-Felder (identisch in beiden Quell-Tabellen)
|
||||
$table->boolean('is_answer')->default(false);
|
||||
$table->string('email')->nullable();
|
||||
$table->text('recipient')->nullable();
|
||||
$table->text('cc')->nullable();
|
||||
$table->text('bcc')->nullable();
|
||||
$table->string('subject')->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->boolean('dir')->default(false);
|
||||
$table->unsignedBigInteger('subdir')->nullable();
|
||||
|
||||
// Nur in customer_mails vorhanden
|
||||
$table->unsignedBigInteger('travel_country_id')->nullable();
|
||||
|
||||
$table->boolean('draft')->default(false);
|
||||
$table->boolean('important')->default(false);
|
||||
$table->boolean('send')->default(false);
|
||||
$table->boolean('fail')->default(false);
|
||||
$table->text('error')->nullable();
|
||||
$table->text('forward')->nullable();
|
||||
|
||||
$table->dateTime('sent_at')->nullable();
|
||||
$table->dateTime('scheduled_at')->nullable();
|
||||
$table->dateTime('delivered_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('contact_id');
|
||||
$table->index('reply_id');
|
||||
$table->index(['legacy_source', 'legacy_id'], 'comm_legacy_idx');
|
||||
|
||||
$table->foreign('inquiry_id', 'comm_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'comm_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('contact_id', 'comm_contact_id_fk')
|
||||
->references('id')->on('contacts')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_mails → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO communications
|
||||
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
|
||||
legacy_source, legacy_id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, customer_id, is_answer, reply_id,
|
||||
'lead_mail', id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, NULL,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at
|
||||
FROM lead_mails
|
||||
");
|
||||
|
||||
// 2. Aus customer_mails → booking_id gesetzt (kann zusätzlich lead_id haben)
|
||||
DB::statement("
|
||||
INSERT INTO communications
|
||||
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
|
||||
legacy_source, legacy_id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, booking_id, customer_id, is_answer, reply_id,
|
||||
'customer_mail', id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at
|
||||
FROM customer_mails
|
||||
");
|
||||
|
||||
// 3. Reply-IDs remappen: alte source-table-IDs auf neue communications-IDs umstellen
|
||||
// Funktioniert weil reply_id immer auf eine Mail derselben Quell-Tabelle zeigt.
|
||||
DB::statement("
|
||||
UPDATE communications c1
|
||||
INNER JOIN communications c2
|
||||
ON c1.legacy_source = c2.legacy_source
|
||||
AND c2.legacy_id = c1.reply_id
|
||||
SET c1.reply_id = c2.id
|
||||
WHERE c1.reply_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('communications');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1b: notices-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_notices + booking_notices in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
|
||||
* (lead → inquiries, customer → contacts).
|
||||
*
|
||||
* Struktur: identisch in beiden Quell-Tabellen — nur inquiry_id vs. booking_id.
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — genau einer ist gesetzt
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Benutzer-Referenzen (integer wie in den Quell-Tabellen)
|
||||
$table->unsignedInteger('from_user_id');
|
||||
$table->unsignedInteger('to_user_id')->nullable();
|
||||
|
||||
$table->text('message')->nullable();
|
||||
$table->boolean('show')->default(false);
|
||||
$table->boolean('important')->default(false);
|
||||
$table->dateTime('edit_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('from_user_id');
|
||||
$table->index('to_user_id');
|
||||
|
||||
$table->foreign('inquiry_id', 'notices_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'notices_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('from_user_id', 'notices_from_user_id_fk')
|
||||
->references('id')->on('users')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('to_user_id', 'notices_to_user_id_fk')
|
||||
->references('id')->on('users')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_notices → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO notices
|
||||
(inquiry_id, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at
|
||||
FROM lead_notices
|
||||
");
|
||||
|
||||
// 2. Aus booking_notices → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO notices
|
||||
(inquiry_id, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at
|
||||
FROM booking_notices
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notices');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1c: attachments-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_files + booking_files in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 + Phase 4a (communications) müssen bereits ausgeführt sein,
|
||||
* da communication_id auf die neue communications-Tabelle verweist.
|
||||
*
|
||||
* Besonderheiten:
|
||||
* - lead_files hat lead_mail_id (FK auf lead_mails) → wird auf communication_id gemappt
|
||||
* - booking_files hat keinen Mail-Bezug → communication_id bleibt NULL
|
||||
* - Spalte `mine` in den Quell-Tabellen ist ein Tippfehler für mime → hier: mime_type
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Verknüpfung zur dazugehörigen Mail (nur bei Anfrage-Anhängen)
|
||||
$table->unsignedBigInteger('communication_id')->nullable();
|
||||
|
||||
// Datei-Metadaten
|
||||
$table->string('identifier')->nullable();
|
||||
$table->string('filename');
|
||||
$table->string('dir')->nullable();
|
||||
$table->string('original_name')->nullable();
|
||||
$table->string('ext')->nullable();
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedInteger('size')->nullable();
|
||||
|
||||
// Herkunft für spätere Analyse / Rollback
|
||||
$table->enum('legacy_source', ['lead_file', 'booking_file']);
|
||||
$table->unsignedBigInteger('legacy_id');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('communication_id');
|
||||
$table->index('identifier', 'attachments_identifier_idx');
|
||||
$table->index(['legacy_source', 'legacy_id'], 'attachments_legacy_idx');
|
||||
|
||||
$table->foreign('inquiry_id', 'att_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'att_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('communication_id', 'att_communication_id_fk')
|
||||
->references('id')->on('communications')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_files → inquiry_id gesetzt; communication_id wird unten gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO attachments
|
||||
(inquiry_id, booking_id, communication_id,
|
||||
identifier, filename, dir, original_name, ext, mime_type, size,
|
||||
legacy_source, legacy_id, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, NULL,
|
||||
identifier, filename, dir, original_name, ext, mine, size,
|
||||
'lead_file', id, created_at, updated_at
|
||||
FROM lead_files
|
||||
");
|
||||
|
||||
// 2. Aus booking_files → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO attachments
|
||||
(inquiry_id, booking_id, communication_id,
|
||||
identifier, filename, dir, original_name, ext, mime_type, size,
|
||||
legacy_source, legacy_id, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id, NULL,
|
||||
identifier, filename, dir, original_name, ext, mine, size,
|
||||
'booking_file', id, created_at, updated_at
|
||||
FROM booking_files
|
||||
");
|
||||
|
||||
// 3. communication_id für lead_files setzen (sofern lead_mail_id gesetzt war)
|
||||
// Nutzt legacy_source + legacy_id der communications-Tabelle.
|
||||
DB::statement("
|
||||
UPDATE attachments a
|
||||
INNER JOIN lead_files lf ON lf.id = a.legacy_id
|
||||
INNER JOIN communications c
|
||||
ON c.legacy_source = 'lead_mail'
|
||||
AND c.legacy_id = lf.lead_mail_id
|
||||
SET a.communication_id = c.id
|
||||
WHERE a.legacy_source = 'lead_file'
|
||||
AND lf.lead_mail_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('attachments');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2a: Alte Mail-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1a (communications) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf communications umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_mails;
|
||||
* SELECT COUNT(*) FROM customer_mails;
|
||||
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'lead_mail';
|
||||
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'customer_mail';
|
||||
* 4. Reply-Chain korrekt: spot-check einiger Datensätze mit reply_id
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// lead_files referenziert lead_mails → erst lead_files droppen oder FK bereits weg
|
||||
// (lead_files wird in 400005 gedroppt — daher hier prüfen ob noch FK vorhanden)
|
||||
// Sicherheitshalber: lead_files.lead_mail_id FK zuerst entfernen falls noch vorhanden
|
||||
if (Schema::hasTable('lead_files') && Schema::hasColumn('lead_files', 'lead_mail_id')) {
|
||||
Schema::table('lead_files', function ($table) {
|
||||
$table->dropForeign('lead_files_lead_mail_id_foreign');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::dropIfExists('lead_mails');
|
||||
Schema::dropIfExists('customer_mails');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2a kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2b: Alte Notiz-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1b (notices) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf notices umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_notices;
|
||||
* SELECT COUNT(*) FROM booking_notices;
|
||||
* SELECT COUNT(*) FROM notices WHERE inquiry_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM notices WHERE booking_id IS NOT NULL;
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('lead_notices');
|
||||
Schema::dropIfExists('booking_notices');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2b kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2c: Alte Datei-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1c (attachments) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf attachments umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_files;
|
||||
* SELECT COUNT(*) FROM booking_files;
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file';
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'booking_file';
|
||||
* 4. communication_id Verknüpfung geprüft:
|
||||
* SELECT COUNT(*) FROM lead_files WHERE lead_mail_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file' AND communication_id IS NOT NULL;
|
||||
* -- Beide Zahlen sollten übereinstimmen
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('lead_files');
|
||||
Schema::dropIfExists('booking_files');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2c kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 1 von 7.
|
||||
*
|
||||
* Erstellt die Haupttabelle `offers`. Jeder Datensatz hier ist ein
|
||||
* logisches Angebot (eine Angebotsnummer). Die tatsächlichen Inhalte
|
||||
* (Texte, Positionen, Preise, PDF, Dokumente) liegen versionsweise in
|
||||
* `offer_versions`. Ab dem ersten Versand erzeugt jede Änderung eine
|
||||
* neue Version (Entscheidung 17.1 des Entwicklungsplans).
|
||||
*
|
||||
* VORBEDINGUNG:
|
||||
* - Modul 3 Phase 2 (Tabellen `contacts` + `inquiries`) muss
|
||||
* eingespielt sein. Siehe dev/customer-bookings/umsetzung.md.
|
||||
*
|
||||
* Tabelle `offers.current_version_id` wird als FK erst in der Migration
|
||||
* 2026_04_17_100007 gesetzt (zyklische Abhängigkeit zu `offer_versions`).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offers', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('offer_number', 32)->unique();
|
||||
|
||||
$t->foreignId('contact_id')
|
||||
->constrained('contacts')
|
||||
->restrictOnDelete();
|
||||
|
||||
$t->foreignId('inquiry_id')
|
||||
->nullable()
|
||||
->constrained('inquiries')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->foreignId('booking_id')
|
||||
->nullable()
|
||||
->constrained('booking')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->enum('status', [
|
||||
'draft',
|
||||
'sent',
|
||||
'accepted',
|
||||
'declined',
|
||||
'expired',
|
||||
'withdrawn',
|
||||
])->default('draft');
|
||||
|
||||
// FK wird in 2026_04_17_100007 nachträglich gesetzt
|
||||
$t->unsignedBigInteger('current_version_id')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['status', 'contact_id']);
|
||||
$t->index('inquiry_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offers');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 2 von 7.
|
||||
*
|
||||
* Versionstabelle der Angebote. Jede Änderung nach dem ersten Versand
|
||||
* erzeugt hier einen neuen Datensatz (version_no = max+1). Eine Version
|
||||
* kapselt den kompletten Inhalt (Texte, Summe, Gültigkeit, PDF-Pfad,
|
||||
* Status). Positionen (`offer_items`) und Anhänge (`offer_files`) hängen
|
||||
* an einer Version — nicht am übergeordneten `offers`-Datensatz.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100001_create_offers_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_versions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_id')
|
||||
->constrained('offers')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->unsignedInteger('version_no');
|
||||
|
||||
$t->enum('status', [
|
||||
'draft',
|
||||
'sent',
|
||||
'accepted',
|
||||
'declined',
|
||||
'expired',
|
||||
'superseded',
|
||||
])->default('draft');
|
||||
|
||||
$t->date('valid_until')->nullable();
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
|
||||
$t->string('headline')->nullable();
|
||||
$t->text('intro_text')->nullable();
|
||||
$t->longText('itinerary_text')->nullable();
|
||||
$t->text('closing_text')->nullable();
|
||||
|
||||
// offer_templates wird in Migration 4 erzeugt — FK dort,
|
||||
// hier zunächst nullable + FK wird über Migration 4 nachgeholt
|
||||
$t->unsignedBigInteger('template_id')->nullable();
|
||||
|
||||
$t->string('pdf_path')->nullable();
|
||||
$t->boolean('pdf_archived')->default(false);
|
||||
|
||||
$t->dateTime('sent_at')->nullable();
|
||||
$t->dateTime('accepted_at')->nullable();
|
||||
|
||||
$t->enum('accepted_via', [
|
||||
'customer_link',
|
||||
'admin',
|
||||
'email',
|
||||
])->nullable();
|
||||
|
||||
// Referenz auf zentral hinterlegte Dokument-Vorlagen,
|
||||
// die mit dieser Version (als Anhang) verknüpft sind
|
||||
$t->json('template_document_ids')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->unique(['offer_id', 'version_no']);
|
||||
$t->index(['offer_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_versions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 3 von 7.
|
||||
*
|
||||
* Einzelne Leistungs-/Positionszeilen einer Angebotsversion.
|
||||
* Hängt an `offer_versions`, NICHT am Offer-Kopf — so bleiben
|
||||
* Positionen einer versendeten Version unveränderlich, während
|
||||
* eine neue Version ihre eigenen Positionen führt.
|
||||
*
|
||||
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
|
||||
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
|
||||
* `metadata` speichert einen Snapshot (Titel, Preis zum Zeitpunkt der
|
||||
* Erstellung), damit Positionen auch nach einer späteren v2-Migration
|
||||
* lesbar bleiben (Risiko R4 im Ticket-Dokument).
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_items', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->unsignedInteger('position')->default(0);
|
||||
|
||||
$t->enum('type', [
|
||||
'travel',
|
||||
'service',
|
||||
'option',
|
||||
'discount',
|
||||
'insurance',
|
||||
'custom',
|
||||
]);
|
||||
|
||||
$t->string('title');
|
||||
$t->text('description')->nullable();
|
||||
|
||||
$t->unsignedInteger('quantity')->default(1);
|
||||
$t->decimal('price_per_unit', 10, 2)->default(0);
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
|
||||
// Bewusst OHNE FK-Constraint — siehe Risiko R4
|
||||
$t->unsignedBigInteger('travel_program_id')->nullable();
|
||||
$t->unsignedBigInteger('fewo_lodging_id')->nullable();
|
||||
|
||||
$t->json('metadata')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_version_id', 'position']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 4 von 7.
|
||||
*
|
||||
* Wiederverwendbare Angebotsvorlagen (Text-/Positions-Blueprints, die
|
||||
* sich in Phase C in der Admin-UI pflegen lassen). Eine Vorlage liefert
|
||||
* Default-Texte + Default-Positionen für neue Angebote.
|
||||
*
|
||||
* Zusatz: Nachträgliche FK-Verknüpfung `offer_versions.template_id`,
|
||||
* weil Migration 2 noch nicht auf diese Tabelle verweisen konnte.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_templates', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
// `branch` existiert schon im CRM — Vorlagen können so pro
|
||||
// Filiale gepflegt werden. Eine spätere Erweiterung auf
|
||||
// `organization_id` (Modul 5) erfolgt additiv.
|
||||
$t->foreignId('branch_id')
|
||||
->nullable()
|
||||
->constrained('branch')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->string('name');
|
||||
$t->text('description')->nullable();
|
||||
|
||||
$t->string('default_headline')->nullable();
|
||||
$t->text('default_intro')->nullable();
|
||||
$t->longText('default_itinerary')->nullable();
|
||||
$t->text('default_closing')->nullable();
|
||||
|
||||
// Array aus [{title, description, type, price_per_unit, quantity}, …]
|
||||
$t->json('default_items')->nullable();
|
||||
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['branch_id', 'is_active']);
|
||||
});
|
||||
|
||||
Schema::table('offer_versions', function (Blueprint $t) {
|
||||
$t->foreign('template_id')
|
||||
->references('id')
|
||||
->on('offer_templates')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('offer_versions', function (Blueprint $t) {
|
||||
$t->dropForeign(['template_id']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('offer_templates');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 5 von 7.
|
||||
*
|
||||
* Dateiablage für Anhänge einer Angebotsversion (freie Uploads, später
|
||||
* auch PDF-Archivkopien). Struktur ist an `booking_files` angelehnt
|
||||
* (identifier, filename, dir, original_name, ext, mime, size), damit
|
||||
* `FileRepository::store()` direkt wiederverwendet werden kann.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_files', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->string('identifier', 64)->nullable();
|
||||
$t->string('filename');
|
||||
$t->string('dir');
|
||||
$t->string('original_name');
|
||||
$t->string('ext', 16);
|
||||
$t->string('mine', 128);
|
||||
$t->unsignedBigInteger('size')->default(0);
|
||||
|
||||
$t->boolean('include_in_pdf')->default(true);
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_version_id', 'identifier']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_files');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 6 von 7.
|
||||
*
|
||||
* Einmal-Token für den kundenseitigen Freigabe-Link
|
||||
* (/angebot/{token} → Phase D). Pro Angebot + Version genau ein aktiver
|
||||
* Token; bei Neuversand einer neuen Version wird der alte Token
|
||||
* `revoked` gesetzt. Tokens sind SHA-256-Hashes; der Klartext wird
|
||||
* ausschließlich im Mail-Link an den Kunden übergeben.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_access_tokens', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_id')
|
||||
->constrained('offers')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->string('token_hash', 64)->unique();
|
||||
$t->dateTime('expires_at')->nullable();
|
||||
$t->dateTime('first_opened_at')->nullable();
|
||||
$t->dateTime('revoked_at')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_id', 'revoked_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_access_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 7 von 7.
|
||||
*
|
||||
* Schließt die FK-Beziehungen, die in den vorherigen Migrationen wegen
|
||||
* zyklischer Abhängigkeiten nicht direkt gesetzt werden konnten:
|
||||
*
|
||||
* 1. `offers.current_version_id` → offer_versions.id (ON DELETE SET NULL)
|
||||
* 2. `bookings.offer_id` → offers.id (ON DELETE SET NULL)
|
||||
*
|
||||
* Letzteres erlaubt die Conversion „Angebot → Buchung" (Ticket B8)
|
||||
* und zeigt im Buchungsdatensatz, aus welchem Angebot er entstanden ist.
|
||||
*
|
||||
* VORBEDINGUNG: alle vorherigen Offers-Migrationen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('offers', function (Blueprint $t) {
|
||||
$t->foreign('current_version_id')
|
||||
->references('id')
|
||||
->on('offer_versions')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('booking', function (Blueprint $t) {
|
||||
// `inquiry_id` kommt aus Modul-3 Phase 2 (war vorher `lead_id`)
|
||||
$t->unsignedBigInteger('offer_id')->nullable()->after('inquiry_id');
|
||||
|
||||
$t->foreign('offer_id')
|
||||
->references('id')
|
||||
->on('offers')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->index('offer_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('booking', function (Blueprint $t) {
|
||||
$t->dropForeign(['offer_id']);
|
||||
$t->dropIndex(['offer_id']);
|
||||
$t->dropColumn('offer_id');
|
||||
});
|
||||
|
||||
Schema::table('offers', function (Blueprint $t) {
|
||||
$t->dropForeign(['current_version_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
132
dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php
Normal file
132
dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
|
||||
*
|
||||
* In der Datenbank wird ausschließlich der SHA-256-Hash des Klartext-
|
||||
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
|
||||
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
|
||||
* per Mail-Link ausgeliefert.
|
||||
*
|
||||
* Pro Angebot + Version existiert genau ein aktiver Token; wird eine
|
||||
* neue Version versendet, setzt der OfferService den Vorgänger auf
|
||||
* `revoked_at`.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $offer_version_id
|
||||
* @property string $token_hash
|
||||
* @property Carbon|null $expires_at
|
||||
* @property Carbon|null $first_opened_at
|
||||
* @property Carbon|null $revoked_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read OfferVersion $version
|
||||
*/
|
||||
class OfferAccessToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'offer_access_tokens';
|
||||
|
||||
protected $fillable = [
|
||||
'offer_id',
|
||||
'offer_version_id',
|
||||
'token_hash',
|
||||
'expires_at',
|
||||
'first_opened_at',
|
||||
'revoked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'offer_id' => 'int',
|
||||
'offer_version_id' => 'int',
|
||||
'expires_at' => 'datetime',
|
||||
'first_opened_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function offer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Offer::class);
|
||||
}
|
||||
|
||||
public function version(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $q): Builder
|
||||
{
|
||||
return $q->whereNull('revoked_at')
|
||||
->where(function (Builder $q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein neues Token für die angegebene Version und liefert
|
||||
* den Klartext-Token zurück (nur einmalig abrufbar). In der
|
||||
* Datenbank wird nur der Hash persistiert.
|
||||
*/
|
||||
public static function generate(
|
||||
Offer $offer,
|
||||
OfferVersion $version,
|
||||
?Carbon $expiresAt = null
|
||||
): array {
|
||||
$plain = Str::random(48);
|
||||
$hash = hash('sha256', $plain);
|
||||
|
||||
/** @var self $token */
|
||||
$token = self::create([
|
||||
'offer_id' => $offer->id,
|
||||
'offer_version_id' => $version->id,
|
||||
'token_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return ['plain' => $plain, 'token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
|
||||
*/
|
||||
public static function findByPlainToken(string $plain): ?self
|
||||
{
|
||||
return self::where('token_hash', hash('sha256', $plain))->first();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
if ($this->revoked_at !== null) {
|
||||
return false;
|
||||
}
|
||||
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Datei-Anhang einer Angebotsversion (Modul 6).
|
||||
*
|
||||
* Struktur ist bewusst an {@see BookingFile} angelehnt (identifier,
|
||||
* filename, dir, original_name, ext, mine, size), damit der vorhandene
|
||||
* `FileRepository::store()` 1:1 wiederverwendet werden kann. `mine`
|
||||
* bleibt so geschrieben (statt `mime`) zur Konsistenz mit der
|
||||
* booking_files-Konvention.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* @property string|null $identifier
|
||||
* @property string $filename
|
||||
* @property string $dir
|
||||
* @property string $original_name
|
||||
* @property string $ext
|
||||
* @property string $mine
|
||||
* @property int $size
|
||||
* @property bool $include_in_pdf
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $version
|
||||
*/
|
||||
class OfferFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'offer_files';
|
||||
|
||||
protected $fillable = [
|
||||
'offer_version_id',
|
||||
'identifier',
|
||||
'filename',
|
||||
'dir',
|
||||
'original_name',
|
||||
'ext',
|
||||
'mine',
|
||||
'size',
|
||||
'include_in_pdf',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'offer_version_id' => 'int',
|
||||
'size' => 'int',
|
||||
'include_in_pdf' => 'bool',
|
||||
];
|
||||
|
||||
public static array $iconExt = [
|
||||
'default' => 'fa fa-file',
|
||||
'pdf' => 'fa fa-file-pdf',
|
||||
'jpg' => 'fa fa-file-image',
|
||||
'jpeg' => 'fa fa-file-image',
|
||||
'png' => 'fa fa-file-image',
|
||||
'doc' => 'fa fa-file-word',
|
||||
'docx' => 'fa fa-file-word',
|
||||
];
|
||||
|
||||
public function version(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function getIconExt(): string
|
||||
{
|
||||
return self::$iconExt[$this->ext] ?? self::$iconExt['default'];
|
||||
}
|
||||
|
||||
public function getURL(bool|string $do = false): string
|
||||
{
|
||||
return route('storage_file', [$this->id, 'offer', $do]);
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return \Storage::disk('offer')->path($this->dir . $this->filename);
|
||||
}
|
||||
|
||||
public function formatBytes(int $precision = 2): string
|
||||
{
|
||||
$size = $this->size;
|
||||
if ($size <= 0) {
|
||||
return (string) $size;
|
||||
}
|
||||
|
||||
$base = log($size) / log(1024);
|
||||
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
|
||||
|
||||
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Position einer Angebotsversion (Modul 6).
|
||||
*
|
||||
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
|
||||
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
|
||||
* `metadata` enthält einen Snapshot der Referenzdaten (Titel, Preis,
|
||||
* Leistungen) — so bleiben Positionen lesbar, auch wenn das Original
|
||||
* später gelöscht / migriert / umbenannt wird.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* @property int $position
|
||||
* @property string $type
|
||||
* @property string $title
|
||||
* @property string|null $description
|
||||
* @property int $quantity
|
||||
* @property float $price_per_unit
|
||||
* @property float $total_price
|
||||
* @property int|null $travel_program_id
|
||||
* @property int|null $fewo_lodging_id
|
||||
* @property array|null $metadata
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $version
|
||||
*/
|
||||
class OfferItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_TRAVEL = 'travel';
|
||||
public const TYPE_SERVICE = 'service';
|
||||
public const TYPE_OPTION = 'option';
|
||||
public const TYPE_DISCOUNT = 'discount';
|
||||
public const TYPE_INSURANCE = 'insurance';
|
||||
public const TYPE_CUSTOM = 'custom';
|
||||
|
||||
protected $table = 'offer_items';
|
||||
|
||||
protected $fillable = [
|
||||
'offer_version_id',
|
||||
'position',
|
||||
'type',
|
||||
'title',
|
||||
'description',
|
||||
'quantity',
|
||||
'price_per_unit',
|
||||
'total_price',
|
||||
'travel_program_id',
|
||||
'fewo_lodging_id',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'offer_version_id' => 'int',
|
||||
'position' => 'int',
|
||||
'quantity' => 'int',
|
||||
'price_per_unit' => 'decimal:2',
|
||||
'total_price' => 'decimal:2',
|
||||
'travel_program_id' => 'int',
|
||||
'fewo_lodging_id' => 'int',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function version(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus Menge × Einzelpreis den Positions-Gesamtpreis berechnen
|
||||
* (Rabatte negativ — gehört in den Service-Layer zur Summierung).
|
||||
*/
|
||||
public function calculateTotal(): float
|
||||
{
|
||||
return round($this->quantity * (float) $this->price_per_unit, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Wiederverwendbare Angebots-Vorlage (Modul 6).
|
||||
*
|
||||
* Liefert Default-Texte + Default-Positionen für neue Angebote.
|
||||
* `default_items` ist ein JSON-Array von Positionen im Schema
|
||||
* [{title, description, type, price_per_unit, quantity}, …].
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $branch_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string|null $default_headline
|
||||
* @property string|null $default_intro
|
||||
* @property string|null $default_itinerary
|
||||
* @property string|null $default_closing
|
||||
* @property array|null $default_items
|
||||
* @property bool $is_active
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property-read Branch|null $branch
|
||||
* @property-read User $creator
|
||||
*/
|
||||
class OfferTemplate extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'offer_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'branch_id',
|
||||
'name',
|
||||
'description',
|
||||
'default_headline',
|
||||
'default_intro',
|
||||
'default_itinerary',
|
||||
'default_closing',
|
||||
'default_items',
|
||||
'is_active',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'branch_id' => 'int',
|
||||
'default_items' => 'array',
|
||||
'is_active' => 'bool',
|
||||
'created_by' => 'int',
|
||||
];
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $q): Builder
|
||||
{
|
||||
return $q->where('is_active', true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Version eines Angebots (Modul 6).
|
||||
*
|
||||
* Jede versendete Fassung wird hier festgehalten — Texte, Positionen
|
||||
* und PDF bleiben damit unveränderlich, sobald ein Kunde sie per
|
||||
* Freigabe-Link einsehen kann. Neue Änderungen nach dem Versand
|
||||
* erzeugen eine neue Version (version_no = max+1, status = draft).
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $version_no
|
||||
* @property string $status
|
||||
* @property Carbon|null $valid_until
|
||||
* @property float $total_price
|
||||
* @property string|null $headline
|
||||
* @property string|null $intro_text
|
||||
* @property string|null $itinerary_text
|
||||
* @property string|null $closing_text
|
||||
* @property int|null $template_id
|
||||
* @property string|null $pdf_path
|
||||
* @property bool $pdf_archived
|
||||
* @property Carbon|null $sent_at
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property string|null $accepted_via
|
||||
* @property array|null $template_document_ids
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read OfferTemplate|null $template
|
||||
* @property-read Collection|OfferItem[] $items
|
||||
* @property-read Collection|OfferFile[] $files
|
||||
* @property-read User $creator
|
||||
*/
|
||||
class OfferVersion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_SENT = 'sent';
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
public const STATUS_DECLINED = 'declined';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
public const ACCEPTED_VIA_LINK = 'customer_link';
|
||||
public const ACCEPTED_VIA_ADMIN = 'admin';
|
||||
public const ACCEPTED_VIA_MAIL = 'email';
|
||||
|
||||
protected $table = 'offer_versions';
|
||||
|
||||
protected $fillable = [
|
||||
'offer_id',
|
||||
'version_no',
|
||||
'status',
|
||||
'valid_until',
|
||||
'total_price',
|
||||
'headline',
|
||||
'intro_text',
|
||||
'itinerary_text',
|
||||
'closing_text',
|
||||
'template_id',
|
||||
'pdf_path',
|
||||
'pdf_archived',
|
||||
'sent_at',
|
||||
'accepted_at',
|
||||
'accepted_via',
|
||||
'template_document_ids',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'offer_id' => 'int',
|
||||
'version_no' => 'int',
|
||||
'valid_until' => 'date',
|
||||
'total_price' => 'decimal:2',
|
||||
'template_id' => 'int',
|
||||
'pdf_archived' => 'bool',
|
||||
'sent_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
'template_document_ids' => 'array',
|
||||
'created_by' => 'int',
|
||||
];
|
||||
|
||||
public function offer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Offer::class);
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferItem::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferFile::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
}
|
||||
158
dev/backups/phase2-offers-2026-04-17/MANIFEST.md
Normal file
158
dev/backups/phase2-offers-2026-04-17/MANIFEST.md
Normal 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
|
||||
```
|
||||
|
|
@ -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()
|
||||
|
|
@ -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',
|
||||
|
|
@ -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',
|
||||
+ ];
|
||||
+}
|
||||
|
|
@ -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);
|
||||
+ }
|
||||
+}
|
||||
|
|
@ -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',
|
||||
|
|
@ -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',
|
||||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
|
|
@ -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++;
|
||||
+ }
|
||||
+}
|
||||
|
|
@ -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'),
|
||||
134
dev/backups/phase2-offers-2026-04-17/restore.sh
Executable file
134
dev/backups/phase2-offers-2026-04-17/restore.sh
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Restore-Script: spielt Phase 2 + Offers-Änderungen zurück in den Workspace.
|
||||
# Voraussetzung: Phase 1 wurde erfolgreich auf Live deployed und migriert.
|
||||
#
|
||||
# Nutzung:
|
||||
# bash dev/backups/phase2-offers-2026-04-17/restore.sh # interaktiv
|
||||
# bash dev/backups/phase2-offers-2026-04-17/restore.sh --force # ohne Nachfrage
|
||||
#
|
||||
# Details siehe MANIFEST.md im selben Verzeichnis.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${BACKUP_DIR}/../../../" && pwd)"
|
||||
FORCE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force|-f) FORCE=true ;;
|
||||
*) echo "Unbekannte Option: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Phase-2 + Offers Restore"
|
||||
echo "=========================================="
|
||||
echo
|
||||
echo " Projekt-Root: ${PROJECT_ROOT}"
|
||||
echo " Backup-Ordner: ${BACKUP_DIR}"
|
||||
echo
|
||||
echo " Folgende Änderungen werden zurückgespielt:"
|
||||
echo " - 18 Migrationen (Phase 2, 3, 4, Offers) nach database/migrations/"
|
||||
echo " - 6 Offer-Models nach app/Models/ (inkl. Überschreiben von Legacy-Offer.php)"
|
||||
echo " - Code-Reverts via git checkout \${WIP_COMMIT} -- <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):"
|
||||
PHASE2_HITS=$(grep -rn "inquiry_id" app/ 2>/dev/null | head -5 || true)
|
||||
if [[ -n "${PHASE2_HITS}" ]]; then
|
||||
echo "${PHASE2_HITS}"
|
||||
echo " → OK, Phase-2-Code ist wieder aktiv."
|
||||
else
|
||||
echo " (keine gefunden — Restore eventuell nicht vollständig)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=========================================="
|
||||
echo " Restore abgeschlossen."
|
||||
echo "=========================================="
|
||||
echo
|
||||
echo " Nächste Schritte:"
|
||||
echo " 1. php -l auf allen geänderten Dateien (Syntax-Check)"
|
||||
echo " 2. composer dump-autoload (neue Models registrieren)"
|
||||
echo " 3. Weiterarbeit an Phase 2 + Offers nach Plan"
|
||||
echo " 4. Phase-2-Migrationen ausführen:"
|
||||
echo " php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php"
|
||||
echo " php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php"
|
||||
echo " php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php"
|
||||
echo
|
||||
241
dev/customer-bookings/phase-1-live-deploy.md
Normal file
241
dev/customer-bookings/phase-1-live-deploy.md
Normal 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 15–30 min)
|
||||
```bash
|
||||
php artisan down --render="errors::503" --secret="dein-geheimer-preview-token"
|
||||
```
|
||||
Mit `--secret` kannst du über `https://domain/dein-geheimer-preview-token` weiter auf Live zugreifen, während alle anderen die 503-Seite sehen.
|
||||
|
||||
3. **Abhängigkeiten abklären**
|
||||
- Läuft auf Live mindestens **PHP 8.1** (Laravel-10-Anforderung)? Prüfen: `php -v`
|
||||
- Ist **Composer** auf dem Live-Server installiert? Falls Managed Hosting: `composer install` vor dem Deploy auf einem Staging-Server ausführen und das `vendor/`-Verzeichnis mitsyncen
|
||||
|
||||
---
|
||||
|
||||
## Upload (rsync / scp / SFTP)
|
||||
|
||||
### Was **muss** hochgeladen werden
|
||||
|
||||
**Neue Dateien:**
|
||||
```
|
||||
database/migrations/2025_04_15_100001_phase1_add_merge_fields_to_customer_table.php
|
||||
database/migrations/2025_04_15_100002_phase1_add_soft_delete_to_customer_table.php
|
||||
app/Models/Contact.php
|
||||
app/Repositories/ContactRepository.php
|
||||
app/Http/Controllers/ContactController.php
|
||||
app/Console/Commands/ContactsFindDuplicates.php
|
||||
app/Console/Commands/ContactsMergeDuplicates.php
|
||||
app/Services/MailDirService.php
|
||||
resources/views/contact/_detail_contact.blade.php
|
||||
resources/views/contact/_detail_history.blade.php
|
||||
resources/views/contact/detail.blade.php
|
||||
resources/views/contact/duplicates.blade.php
|
||||
resources/views/contact/index.blade.php
|
||||
database/factories/BookingFactory.php
|
||||
database/factories/CustomerFactory.php
|
||||
database/factories/LeadFactory.php
|
||||
tests/Feature/Api/BookingImportTest.php
|
||||
tests/Feature/Auth/LoginTest.php
|
||||
tests/Feature/BookingControllerTest.php
|
||||
tests/Unit/Services/UtilTest.php
|
||||
```
|
||||
|
||||
**Modifizierte Dateien** — vollständige Upload-Liste generieren:
|
||||
|
||||
```bash
|
||||
cd /workspace/mein.sterntours.de
|
||||
git diff --name-only HEAD^ \
|
||||
| grep -Ev "^(dev/|tests/|\.env|\.mcp\.json$|CLAUDE\.md$|boost\.json$|_ide_helper|\.devcontainer/|bootstrap/cache/|mein\.sterntours\.de\.code-workspace$|docker-compose\.yml$|public/storage$)" \
|
||||
> /tmp/phase1-files-to-upload.txt
|
||||
cat /tmp/phase1-files-to-upload.txt
|
||||
wc -l /tmp/phase1-files-to-upload.txt
|
||||
```
|
||||
|
||||
Das filtert die unerwünschten Dateien automatisch raus.
|
||||
|
||||
Die wichtigsten modifizierten Dateien (komplette Liste in der generierten Textdatei):
|
||||
- `app/Models/Customer.php`, `Booking.php`, `Lead.php` (und viele weitere Models — Laravel-10-Upgrade)
|
||||
- `app/Http/Controllers/*.php` (Controllers mit Laravel-10-Anpassungen)
|
||||
- `app/Http/Kernel.php`, `app/Console/Kernel.php`, `app/Providers/*.php`, `app/Http/Middleware/TrustProxies.php` (Laravel-10-Upgrade)
|
||||
- `app/Repositories/*.php` (Laravel-10: `Storage::disk()->path()` statt `getAdapter()->getPathPrefix()`)
|
||||
- `app/Libraries/CreatePDF.php`
|
||||
- `routes/web.php` (neue `/contacts`-Routen)
|
||||
- `resources/views/layouts/application.blade.php`, `resources/views/layouts/includes/layout-sidenav.blade.php`
|
||||
- `config/trustedproxy.php`
|
||||
- `packages/digital-bird/shoppingcart/**`, `packages/iqcontent/laravel-filemanager/**` (Package-Updates)
|
||||
- `composer.json`, `composer.lock`
|
||||
- `phpunit.xml`
|
||||
|
||||
### Was **nicht** hochgeladen werden darf
|
||||
|
||||
- **Alles unter `dev/`** — dort liegt Entwicklungsdokumentation und die Backups (Phase 2, Offers)
|
||||
- `.env`, `.env.*` — enthält produktive Credentials, wird separat verwaltet
|
||||
- `_ide_helper.php`, `_ide_helper_models.php` — lokale IDE-Hilfen (von barryvdh/laravel-ide-helper generiert)
|
||||
- `bootstrap/cache/config.php` — wird auf Live per `php artisan config:cache` frisch generiert
|
||||
- `.mcp.json`, `CLAUDE.md`, `boost.json` — Dev-Tools-Konfiguration
|
||||
- `.devcontainer/` — Entwicklungsumgebung (VSCode-Devcontainer)
|
||||
- `storage/logs/*`, `storage/framework/cache/*`, `storage/framework/views/*`, `storage/framework/sessions/*` — Runtime-Daten
|
||||
- `vendor/` — wird auf Live per `composer install` generiert (außer Managed Hosting, siehe oben)
|
||||
- `node_modules/` — genauso
|
||||
- `.git/` — Repo-Metadaten
|
||||
- `tests/` — nur auf Test relevant; auf Live-Production nicht nötig (aber nicht schädlich, falls mit rauf kommt)
|
||||
- `mein.sterntours.de.code-workspace` — IDE-Workspace-Datei
|
||||
- `docker-compose.yml` — lokale Dev-Orchestrierung
|
||||
|
||||
### Beispiel rsync-Kommando (als Orientierung)
|
||||
|
||||
```bash
|
||||
# Von lokal → Live
|
||||
# Achtung: --dry-run zuerst, dann ohne --dry-run wiederholen!
|
||||
rsync -av --dry-run \
|
||||
--exclude='.git/' \
|
||||
--exclude='dev/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='vendor/' \
|
||||
--exclude='storage/logs/' \
|
||||
--exclude='storage/framework/cache/' \
|
||||
--exclude='storage/framework/views/' \
|
||||
--exclude='storage/framework/sessions/' \
|
||||
--exclude='storage/app/public/temp/' \
|
||||
--exclude='public/storage' \
|
||||
--exclude='.env*' \
|
||||
--exclude='bootstrap/cache/' \
|
||||
--exclude='_ide_helper*' \
|
||||
--exclude='.mcp.json' \
|
||||
--exclude='CLAUDE.md' \
|
||||
--exclude='boost.json' \
|
||||
--exclude='.devcontainer/' \
|
||||
/workspace/mein.sterntours.de/ \
|
||||
user@live-server:/pfad/zu/mein.sterntours.de/
|
||||
```
|
||||
|
||||
Falls du **nur geänderte Dateien** syncen möchtest (sicherer bei großer Codebase):
|
||||
```bash
|
||||
git diff --name-only HEAD^ | grep -v "^dev/" > /tmp/phase1-files.txt
|
||||
rsync -av --files-from=/tmp/phase1-files.txt . user@live-server:/pfad/zu/mein.sterntours.de/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ausführung auf Live-Server
|
||||
|
||||
Nach dem Upload, per SSH auf dem Live-Server:
|
||||
|
||||
```bash
|
||||
cd /pfad/zu/mein.sterntours.de
|
||||
|
||||
# 1. Composer Dependencies aktualisieren (Laravel 10 Upgrade!)
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# 2. Alle Caches leeren (wichtig wegen Struktur-Änderungen)
|
||||
php artisan config:clear
|
||||
php artisan route:clear
|
||||
php artisan view:clear
|
||||
php artisan cache:clear
|
||||
|
||||
# 3. DB-Migrationen ausführen (nur die 2 Phase-1-Migrationen)
|
||||
php artisan migrate --force
|
||||
# Sollte zeigen:
|
||||
# Running: 2025_04_15_100001_phase1_add_merge_fields_to_customer_table
|
||||
# Running: 2025_04_15_100002_phase1_add_soft_delete_to_customer_table
|
||||
|
||||
# 4. Autoload neu generieren (neue Klassen: Contact, ContactRepository, MailDirService)
|
||||
composer dump-autoload --optimize
|
||||
|
||||
# 5. Duplicats-Analyse (read-only, erzeugt Reports)
|
||||
php artisan contacts:find-duplicates
|
||||
# → Reports unter storage/app/contacts/duplicates/*.csv
|
||||
|
||||
# 6. Duplicats-Merging (DRY-RUN zuerst!)
|
||||
php artisan contacts:merge-duplicates --dry-run
|
||||
# Review der Ausgabe. Wenn OK:
|
||||
|
||||
# 7. Duplicats-Merging (echt)
|
||||
php artisan contacts:merge-duplicates --confidence=HIGH --force
|
||||
# Merged nur hochkonfidente Duplikate automatisch.
|
||||
# Mittlere/niedrige Konfidenz bleibt für manuelle Review.
|
||||
|
||||
# 8. Production-Caches wieder aufbauen (für Performance)
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
# Optional: php artisan event:cache
|
||||
# NICHT php artisan optimize (das cached auch, aber einige Setups haben damit Probleme)
|
||||
|
||||
# 9. Maintenance-Mode deaktivieren
|
||||
php artisan up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Smoke-Tests nach dem Deploy
|
||||
|
||||
Nach dem `php artisan up` diese Kernfunktionen manuell testen:
|
||||
|
||||
- [ ] Login funktioniert (`/login`)
|
||||
- [ ] Buchungsliste lädt (`/booking`)
|
||||
- [ ] Einzelne Buchung öffnen, Detailseite lädt
|
||||
- [ ] Anfragenliste lädt (`/lead`)
|
||||
- [ ] Einzelne Anfrage öffnen, Detailseite lädt
|
||||
- [ ] **NEU**: Kontakte-Liste lädt (`/contacts` oder `/contact` je nach Route)
|
||||
- [ ] **NEU**: Kontakte-Duplikats-Übersicht lädt (`/contacts/duplicates` oder ähnlich)
|
||||
- [ ] PDF-Erzeugung funktioniert (Buchungsbestätigung, Voucher, Storno) — testet `Storage::disk()->path()`-Änderung
|
||||
- [ ] Mail-Versand über CustomerMail funktioniert
|
||||
- [ ] Admin-Report läuft durch (`/admin/report`)
|
||||
|
||||
---
|
||||
|
||||
## Rollback-Plan (falls etwas schiefgeht)
|
||||
|
||||
1. **Maintenance-Mode aktivieren:** `php artisan down`
|
||||
2. **DB-Rollback:**
|
||||
```bash
|
||||
php artisan migrate:rollback --step=2
|
||||
```
|
||||
oder — falls das fehlschlägt — das zuvor erstellte DB-Backup einspielen.
|
||||
3. **Dateisystem-Rollback:** das zuvor erstellte Dateisystem-Backup zurückspielen (überschreiben).
|
||||
4. **Caches leeren + Up:**
|
||||
```bash
|
||||
php artisan config:clear && php artisan cache:clear && php artisan view:clear && php artisan up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nach erfolgreichem Live-Deploy
|
||||
|
||||
1. **Lokal** im Workspace: Restore-Script laufen lassen, um Phase 2 + Offers zurückzuholen:
|
||||
```bash
|
||||
cd /workspace/mein.sterntours.de
|
||||
bash dev/backups/phase2-offers-2026-04-17/restore.sh
|
||||
```
|
||||
2. Der Workspace enthält dann wieder Phase 2 Code + Offers-Modul. Weitermachen nach Plan in:
|
||||
- `dev/customer-bookings/umsetzung.md` (Phase 2–4)
|
||||
- `dev/offers/umsetzung.md` (Offers-Modul)
|
||||
3. **Master-Branch** im Git kann bei Gelegenheit mit dem WIP-Commit `e3dc1af` aktualisiert werden (push), damit der Remote-Stand den aktuellen Arbeitsstand abbildet.
|
||||
|
||||
---
|
||||
|
||||
## Dateien in diesem Backup
|
||||
|
||||
- `dev/backups/phase2-offers-2026-04-17/MANIFEST.md` — detaillierte Übersicht
|
||||
- `dev/backups/phase2-offers-2026-04-17/FILES/migrations/` — 18 Migrations-Dateien
|
||||
- `dev/backups/phase2-offers-2026-04-17/FILES/models/` — 6 Offer-Models
|
||||
- `dev/backups/phase2-offers-2026-04-17/PATCHES/` — 11 Diff-Dateien zur Dokumentation
|
||||
- `dev/backups/phase2-offers-2026-04-17/restore.sh` — automatisches Restore
|
||||
|
||||
**Zusätzliche Sicherheitsnetze:**
|
||||
- Tarball: `../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz` (182 MB, kompletter Workspace-State vor dem Rückbau)
|
||||
- Git-Commit: `e3dc1af` auf lokalem `master` (nicht gepusht) — enthält Phase 1 + 2 + Offers gemischt
|
||||
305
dev/customer-bookings/phase-2-live-deploy.md
Normal file
305
dev/customer-bookings/phase-2-live-deploy.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# Phase-2-Live-Deploy — Anleitung
|
||||
|
||||
**Ausgangssituation (nach Phase-1-Live-Deploy am 2026-04-17):**
|
||||
- Live: Phase 1 ✅ (Code + DB-Migrationen 25, 26)
|
||||
- Test: Phase 1 ✅ + Phase 2 ✅ (Code + DB-Migrationen 25–29, verifiziert)
|
||||
- Workspace (lokal): Phase 1 + Phase 2 + Offers-Code ready
|
||||
|
||||
Dieses Handbuch beschreibt, wie Phase 2 vom Test-Stand auf Live übertragen wird. Phase 2 umfasst zwei atomar zusammengehörige Teile plus eine kleine Ergänzung aus Phase 1:
|
||||
|
||||
1. **DB-Schema-Änderungen** (3 Migrationen, breaking):
|
||||
- `RENAME TABLE customer → contacts`
|
||||
- `RENAME TABLE lead → inquiries`
|
||||
- Spalte `booking.lead_id → booking.inquiry_id` (inkl. FK-Index-Neuaufbau)
|
||||
2. **Application-Code-Änderungen** (23 modifizierte Dateien):
|
||||
- Models `Customer`, `Contact`, `Lead`, `Booking` mit neuen `$table`-Werten bzw. Spaltennamen
|
||||
- Repositories, Controllers, Services, Commands, Views — alle Referenzen auf `booking.lead_id` → `inquiry_id` umgestellt; Raw-SQL auf `contacts`/`inquiries` umgestellt
|
||||
- Smoke-Test-Fixes (2026-04-17): `Lead::bookings()` mit explizitem FK `inquiry_id`; `orderColumn`-SQL in `ContactController`/`ReportController`/`ReportBookingController`/`ReportLeadsController` auf `contacts.*` gezogen.
|
||||
3. **Phase-1-Hotfixes — nullable Parameter in den Mail-Dir-Services** (3 modifizierte Dateien):
|
||||
- `app/Services/Booking.php`, `app/Services/Lead.php`, `app/Services/MailDirService.php`
|
||||
- `int $mailDirId` → `?int $mailDirId` bei `getCustomerMailName()` / `getCustomerMailEmails()` / `resolveModel()` (behebt Mail-Dialog-Crash wenn `customer_mails.mail_dir_id` / `lead_mails.mail_dir_id` NULL ist).
|
||||
- `string $subdir` → `?string $subdir` bei `setOutputDirs()` / `setOutputDir()` (behebt Mail-Dialog-Crash wenn `$mail_sdir_id` NULL ist, z.B. bei Entwürfen oder Top-Level-Ordnern).
|
||||
- Fachlich eigentlich Phase-1-Bugs (Laravel-10/PHP-8-strict-typing vs. `null`-Werte aus nullable DB-Spalten), die aber erst durch die Test-Durchläufe am 2026-04-17 sichtbar wurden. Wir liefern die Fixes zusammen mit Phase 2 aus, weil ein zweites separates Wartungsfenster für drei Service-Dateien nicht sinnvoll wäre und die Änderungen keinerlei DB-Abhängigkeit haben (also auch außerhalb der DB-Migration risikoarm sind).
|
||||
|
||||
> **Wichtig:** DB und Phase-2-Code müssen atomar gemeinsam live gehen. Zwischen den beiden gibt es kein kompatibles Fenster, deswegen ist ein **Maintenance-Window von ~10–20 min Pflicht**. Die 3 Phase-1-Hotfix-Services werden im selben rsync-Schritt mit hochgeladen.
|
||||
|
||||
---
|
||||
|
||||
## Vorbereitung (am Tag vor dem Deploy)
|
||||
|
||||
1. **Timing**: Wartungsfenster einplanen, idealerweise außerhalb der Geschäftszeiten. ~20 Minuten reichen erfahrungsgemäß.
|
||||
2. **Deploy-Freigabe**: Phase 1 auf Live läuft stabil (mindestens 24–48 h), keine Regressions-Meldungen.
|
||||
3. **Team-Kommunikation**: Mitarbeiter:innen informieren — während des Wartungsfensters keine Mails, keine Buchungen anlegen, keine PDFs drucken.
|
||||
|
||||
### Vorab-Kontrolle auf Live (lesend, keine Änderungen)
|
||||
|
||||
```bash
|
||||
# Live-Server: aktueller Stand?
|
||||
php artisan --version
|
||||
# → sollte 10.50+ sein (Phase 1 hat Laravel 10 gebracht)
|
||||
|
||||
php artisan migrate:status | grep phase1
|
||||
# → sollte beide phase1-Migrationen mit "Ran" zeigen
|
||||
|
||||
mysql -e "SHOW TABLES LIKE 'customer';"
|
||||
mysql -e "SHOW TABLES LIKE 'lead';"
|
||||
# → beide müssen noch existieren (Phase 2 NICHT ausgeführt)
|
||||
|
||||
mysql -e "SHOW COLUMNS FROM booking LIKE 'lead_id';"
|
||||
# → lead_id muss existieren
|
||||
```
|
||||
|
||||
Wenn einer dieser Checks nicht das erwartete Ergebnis liefert: **STOP**, klären bevor weitergemacht wird.
|
||||
|
||||
### Smoke-Tests auf Test (am Deploy-Tag, vor dem Live-Deploy)
|
||||
|
||||
Diese Liste einmal komplett auf `https://mein.sterntours.test` durchgehen. Wenn einer fehlschlägt → Fehler fixen, Live-Deploy verschieben.
|
||||
|
||||
- [ ] Login funktioniert, Dashboard lädt
|
||||
- [ ] **Buchungen**: `/booking` lädt, Filter- und Sortier-Funktionen arbeiten, Anfrage-Nr-Spalte zeigt korrekte Werte
|
||||
- [ ] **Buchungsdetail** öffnen, alle Tabs laden (Buchung, Mails, Notizen, Dokumente)
|
||||
- [ ] **PDF-Generierung** aus einer Buchung: Buchungsauftrag, Reisebestätigung, Voucher (Kunde + Agentur), Storno
|
||||
- [ ] **Anfragen**: `/lead` lädt, einzelne Anfrage öffnen
|
||||
- [ ] **Anfrage → Buchung erzeugen** (legt `booking.inquiry_id` korrekt an)
|
||||
- [ ] **Kontakte**: `/contacts` lädt, einzelner Kontakt öffnet, `leads_count` + `bookings_count` zeigen korrekte Werte (testet die Beziehungen über die neuen Tabellennamen)
|
||||
- [ ] **Kontakt-Duplikate**: `/contact/duplicates` lädt, Zähler stimmen
|
||||
- [ ] **Mail-Versand** aus einer Buchung (Eintrag in `customer_mails.lead_id` muss identisch zur `booking.inquiry_id` sein)
|
||||
- [ ] **Mail-Dialog öffnen** bei Buchung und Anfrage (Modal "Neue Mail schreiben" / "Mail anzeigen") — testet den nullable-`mail_dir_id`-Fix in `Services\Booking::getCustomerMailName()` / `Services\Lead::getCustomerMailName()`.
|
||||
- [ ] **Admin-Reports**: `/admin/report`, `/admin/report-bookings`, `/admin/report-provider`, `/admin/report-leads` laden UND jeweils einmal nach Kundenname sortieren (testet die `orderColumn`-Fixes für `contacts.firstname/name`) + CSV-Export
|
||||
- [ ] **BookingImport über API** (falls testbar — feuert gegen `/api/booking/import`) legt Datensatz mit korrekter `inquiry_id` an
|
||||
- [ ] Newsletter-Sync-Command als Dry-Run: `php artisan newsletter:sync-kulturreisen --dry-run` (falls implementiert)
|
||||
|
||||
---
|
||||
|
||||
## Deploy-Ablauf Live
|
||||
|
||||
### Schritt 1 — Upload-Liste generieren (lokal)
|
||||
|
||||
Der Phase-2-Delta gegen Phase 1: **3 neue Migrationen + 24 modifizierte Dateien (Phase 2) + 3 Phase-1-Hotfix-Services = 30 Dateien.** Exakte Liste:
|
||||
|
||||
```bash
|
||||
cd /workspace/mein.sterntours.de
|
||||
cat > /tmp/phase2-files-to-upload.txt <<'EOF'
|
||||
database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
app/Models/Booking.php
|
||||
app/Models/Customer.php
|
||||
app/Models/Contact.php
|
||||
app/Models/Lead.php
|
||||
app/Repositories/BookingPDFRepository.php
|
||||
app/Repositories/LeadRepository.php
|
||||
app/Repositories/CustomerMailRepository.php
|
||||
app/Http/Controllers/RequestController.php
|
||||
app/Http/Controllers/API/BookingController.php
|
||||
app/Http/Controllers/Admin/ReportController.php
|
||||
app/Http/Controllers/Admin/ReportBookingController.php
|
||||
app/Http/Controllers/Admin/ReportProviderController.php
|
||||
app/Http/Controllers/Admin/ReportLeadsController.php
|
||||
app/Http/Controllers/LeadController.php
|
||||
app/Http/Controllers/CustomerController.php
|
||||
app/Http/Controllers/ContactController.php
|
||||
app/Services/BookingImport.php
|
||||
app/Services/Booking.php
|
||||
app/Services/Lead.php
|
||||
app/Services/MailDirService.php
|
||||
app/Console/Commands/SyncNewsletterKulturreisen.php
|
||||
app/Console/Commands/ContactsFindDuplicates.php
|
||||
app/Console/Commands/ContactsMergeDuplicates.php
|
||||
resources/views/customer/mail/modal-show-mail-inner.blade.php
|
||||
resources/views/contact/index.blade.php
|
||||
resources/views/pdf/components/booking_head.blade.php
|
||||
resources/views/pdf/components/booking_header.blade.php
|
||||
EOF
|
||||
|
||||
wc -l /tmp/phase2-files-to-upload.txt
|
||||
# → sollte 30 zeigen (3 Migrationen + 24 Phase-2-Code + 3 Phase-1-Hotfix-Services)
|
||||
```
|
||||
|
||||
**Erklärung zu den zusätzlichen Dateien gegenüber einer rein strukturellen Phase-2-Liste:**
|
||||
- `ReportBookingController.php` — Smoke-Test-Fix am 2026-04-17: `orderColumn('customer.firstname/name', …)` → `contacts.*`. War schon vor dem Fix nicht perfekt, fiele aber beim ersten Sortier-Klick nach Phase-2-Rename hart auf.
|
||||
- `resources/views/contact/index.blade.php` — Smoke-Test-Fix am 2026-04-17: DataTables-Column-Definition `name: 'customer.id'` → `name: 'contacts.id'` (+ passender `orderColumn('contacts.id', …)` / `filterColumn('contacts.id', …)` im `ContactController`). Yajra verwendet `name:` als raw SQL-Spalte, wenn kein `orderColumn` matched — deswegen muss der Identifier zwischen Blade und Controller konsistent sein und dem neuen Tabellennamen entsprechen.
|
||||
- `app/Services/Booking.php`, `Lead.php`, `MailDirService.php` — Phase-1-Hotfix: nullable-Parameter (`?int $mailDirId`, `?string $subdir`). Behebt Laravel-10/PHP-8-strict-typing-Fehler bei Mail-Dialogen mit NULL-Werten aus `customer_mails` / `lead_mails`.
|
||||
|
||||
> **Ausdrücklich NICHT mit hochladen:** Phase-3-/Phase-4-Migrationen (5+6 Stück), Offers-Migrationen (7 Stück), Offer-Models (6 Stück), `config/filesystems.php` (offer-Disk). Diese kommen erst mit späteren Modulen und würden auf Live ungewollt Tabellen anlegen oder Code-Erwartungen brechen.
|
||||
|
||||
### Schritt 1b — Upload-Liste gegen den Workspace verifizieren (Sanity-Check)
|
||||
|
||||
Damit sich hier kein Tippfehler einschleicht, einmal prüfen, dass alle 29 Einträge tatsächlich im Workspace existieren:
|
||||
|
||||
```bash
|
||||
cd /workspace/mein.sterntours.de
|
||||
MISSING=0
|
||||
while read f; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "FEHLT: $f"
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
done < /tmp/phase2-files-to-upload.txt
|
||||
echo "---"
|
||||
echo "Fehlend: $MISSING (sollte 0 sein)"
|
||||
echo "Gesamt: $(wc -l < /tmp/phase2-files-to-upload.txt)"
|
||||
```
|
||||
|
||||
### Schritt 2 — DB-Backup auf Live
|
||||
|
||||
```bash
|
||||
# Auf dem Live-Server, vor dem Deploy
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||
mysqldump --single-transaction --routines --triggers \
|
||||
--databases DEIN_DB_NAME \
|
||||
| gzip > /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz
|
||||
|
||||
# Größe prüfen:
|
||||
ls -lh /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz
|
||||
```
|
||||
|
||||
Falls `mysqldump` nicht zur Hand: das Hoster-Tool nutzen (phpMyAdmin-Export, Plesk/cPanel-Backup-Feature, etc.).
|
||||
|
||||
### Schritt 3 — Maintenance-Mode an
|
||||
|
||||
```bash
|
||||
cd /pfad/zu/mein.sterntours.de
|
||||
php artisan down --render="errors::503" --secret="phase-2-deploy-$(date +%s)"
|
||||
# → Secret-URL wird ausgegeben, z.B.:
|
||||
# https://domain/phase-2-deploy-1746123456
|
||||
# Damit kannst du während der Wartung selbst noch als Admin auf die Seite.
|
||||
```
|
||||
|
||||
### Schritt 4 — Upload per rsync
|
||||
|
||||
```bash
|
||||
# Vom lokalen Workspace (oder Test-Server) auf Live
|
||||
rsync -av --files-from=/tmp/phase2-files-to-upload.txt \
|
||||
/workspace/mein.sterntours.de/ \
|
||||
user@live-server:/pfad/zu/mein.sterntours.de/
|
||||
```
|
||||
|
||||
Alternative mit scp, falls rsync nicht verfügbar:
|
||||
```bash
|
||||
while read f; do
|
||||
scp "/workspace/mein.sterntours.de/$f" "user@live-server:/pfad/zu/mein.sterntours.de/$f"
|
||||
done < /tmp/phase2-files-to-upload.txt
|
||||
```
|
||||
|
||||
### Schritt 5 — Code-Caches leeren
|
||||
|
||||
Auf dem Live-Server:
|
||||
|
||||
```bash
|
||||
cd /pfad/zu/mein.sterntours.de
|
||||
php artisan cache:clear
|
||||
php artisan config:clear
|
||||
php artisan route:clear
|
||||
php artisan view:clear
|
||||
composer dump-autoload --optimize
|
||||
```
|
||||
|
||||
### Schritt 6 — DB-Migrationen gezielt ausführen
|
||||
|
||||
> **Wichtig: KEIN `php artisan migrate` ohne `--path`!**
|
||||
> Wenn man nacktes `migrate` aufruft, werden auch die im Workspace liegenden Phase-3-, Phase-4- und Offer-Migrationen ausgeführt, die noch nicht deployreif sind. Deswegen gezielt per `--path`:
|
||||
|
||||
```bash
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
|
||||
|
||||
# Verifikation
|
||||
php artisan migrate:status | grep phase2
|
||||
# → alle drei sollten jetzt "Ran" zeigen
|
||||
```
|
||||
|
||||
Erwartete Dauer: wenige Sekunden. `RENAME TABLE` ist in MySQL eine Metadaten-Operation und läuft unabhängig von der Tabellengröße fast instantan. Der Spaltenrename `booking.lead_id → inquiry_id` ist je nach MySQL-Version ebenfalls schnell (MySQL 8+ mit `ALGORITHM=INSTANT`) oder dauert ein paar Sekunden (mit Fallback).
|
||||
|
||||
### Schritt 7 — Production-Caches neu aufbauen
|
||||
|
||||
```bash
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
```
|
||||
|
||||
### Schritt 8 — Maintenance-Mode aus
|
||||
|
||||
```bash
|
||||
php artisan up
|
||||
```
|
||||
|
||||
### Schritt 9 — Live-Smoke-Tests
|
||||
|
||||
Dieselbe Checkliste wie bei Test (oben). Besonders aufpassen:
|
||||
- PDF-Erzeugung (Voucher einer aktuellen Buchung)
|
||||
- Mail-Versand aus einer Buchung
|
||||
- Anfrage-Erstellung + daraus Buchung ableiten
|
||||
- `/contacts/duplicates` öffnen — zeigt korrekte Counts
|
||||
|
||||
Bei Problemen sofort zu **Rollback-Plan** (unten) wechseln.
|
||||
|
||||
---
|
||||
|
||||
## Rollback-Plan (falls Phase 2 auf Live scheitert)
|
||||
|
||||
Der Rollback muss ebenfalls atomar passieren: **Code und DB zusammen zurück.**
|
||||
|
||||
### Schritt A — Maintenance-Mode an
|
||||
|
||||
```bash
|
||||
php artisan down --render="errors::503"
|
||||
```
|
||||
|
||||
### Schritt B — DB zurückrollen
|
||||
|
||||
Option 1 (sauber, per Migration):
|
||||
```bash
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
|
||||
```
|
||||
(Die Migrationen haben symmetrische `down()`-Methoden, die die Renames rückgängig machen.)
|
||||
|
||||
Option 2 (Notfall, falls `migrate:rollback` fehlschlägt):
|
||||
```bash
|
||||
gunzip < /backup/live-db-before-phase2-${TIMESTAMP}.sql.gz | mysql DEIN_DB_NAME
|
||||
```
|
||||
|
||||
### Schritt C — Code zurück
|
||||
|
||||
Entweder die pre-Phase-2-Versionen der 22 Dateien zurückspielen (z.B. aus dem DB-Backup-Zeitpunkt-Dateisystem-Backup), oder mit git Revert-Commit:
|
||||
```bash
|
||||
# Wenn Live-Server ein Git-Checkout ist:
|
||||
git checkout <phase-1-commit-id> -- app/ resources/ config/
|
||||
```
|
||||
|
||||
### Schritt D — Caches + Up
|
||||
|
||||
```bash
|
||||
php artisan cache:clear && php artisan config:clear && php artisan view:clear && php artisan route:clear
|
||||
composer dump-autoload
|
||||
php artisan config:cache && php artisan route:cache && php artisan view:cache
|
||||
php artisan up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dokumentation nach erfolgreichem Live-Deploy
|
||||
|
||||
1. `dev/customer-bookings/umsetzung.md` updaten: Phase 2 auf Live per 2026-MM-DD.
|
||||
2. Dieses Handbuch als "durchgeführt" markieren, ggf. Learnings ergänzen.
|
||||
3. Git-Commit auf Live-Server (falls Git-basiert): mit Tag `deploy/phase-2-live-2026-MM-DD`.
|
||||
4. Nächster Schritt: Entscheidung, wann Phase 3 (Participants-Unification), Phase 4 (Communications-Unification) oder das Offers-Modul an die Reihe kommen.
|
||||
|
||||
---
|
||||
|
||||
## Hintergrund: warum das Maintenance-Window nötig ist
|
||||
|
||||
Zwischen DB-Rename und Code-Deploy ist der Zustand inkompatibel:
|
||||
|
||||
| Zustand | DB hat | Code erwartet | Resultat |
|
||||
|---|---|---|---|
|
||||
| Vor Deploy | `customer`, `lead`, `booking.lead_id` | `customer`, `lead`, `booking.lead_id` | OK |
|
||||
| DB-migriert, Code alt | `contacts`, `inquiries`, `booking.inquiry_id` | `customer`, `lead`, `booking.lead_id` | **Fehler: Table doesn't exist** |
|
||||
| DB alt, Code neu | `customer`, `lead`, `booking.lead_id` | `contacts`, `inquiries`, `booking.inquiry_id` | **Fehler: Table doesn't exist** |
|
||||
| Nach Deploy | `contacts`, `inquiries`, `booking.inquiry_id` | `contacts`, `inquiries`, `booking.inquiry_id` | OK |
|
||||
|
||||
Deswegen: Maintenance-Mode an → Code + DB gleichzeitig → Maintenance-Mode aus. Alles innerhalb eines Fensters, in dem keine Requests an die App laufen.
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
# Umsetzung: Neustrukturierung Customer / Lead / Booking
|
||||
|
||||
**Status:** Phase 1 auf Testsystem abgeschlossen — Contacts-Modul live; **Phase-2-App-Code vorbereitet (deploy-bereit)**
|
||||
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Phase 2 auf **Test** erfolgreich migriert und verifiziert — bereit für Live-Deploy.
|
||||
**Erstellt:** April 2025
|
||||
**Letzte Aktualisierung:** 2026-04-17
|
||||
**Konzept:** [konzept.md](konzept.md)
|
||||
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -10,12 +12,70 @@
|
|||
|
||||
| Phase | Status | Deployed auf Test? | Deployed auf Live? |
|
||||
|-------|--------|-------------------|-------------------|
|
||||
| Phase 1 — Contact-Deduplizierung | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
|
||||
| Phase 1 — Contacts-Modul (neuer Code) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein |
|
||||
| Phase 2 — App-Code (Models/Repos/Controller/Views auf `contacts`/`inquiries`/`inquiry_id`) | ✅ Abgeschlossen | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ⬜ Ausstehend (Code ist deploy-ready) | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 1 — Contact-Deduplizierung | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-17** |
|
||||
| Phase 1 — Contacts-Modul (neuer Code) | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-17** |
|
||||
| Phase 2 — App-Code (Models/Repos/Controller/Views auf `contacts`/`inquiries`/`inquiry_id`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (Deploy-Handbuch fertig) |
|
||||
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ✅ Abgeschlossen | ✅ **Ja, Batch 27–29** | ⬜ Nein (Deploy-Handbuch fertig) |
|
||||
| Phase 2 — Smoke-Test-Fixes 2026-04-17 (`Lead::bookings()` FK; `orderColumn`-SQL in 4 Controllern; Blade-Column-`name:` in `contact/index.blade.php`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt) |
|
||||
| Phase-1-Hotfix — nullable Parameter (`?int $mailDirId`, `?string $subdir`) in 3 Service-Klassen (Mail-Dialoge mit NULL-Werten) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt, siehe Handbuch) |
|
||||
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
||||
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
||||
|
||||
### Verifikation Phase 2 auf Test (2026-04-17)
|
||||
|
||||
Smoke-Queries nach erfolgter Migration:
|
||||
|
||||
```
|
||||
Tabellen:
|
||||
customer NOT FOUND ← umbenannt ✓
|
||||
contacts EXISTS ← neu ✓
|
||||
lead NOT FOUND ← umbenannt ✓
|
||||
inquiries EXISTS ← neu ✓
|
||||
booking EXISTS ✓
|
||||
|
||||
booking-Spalten:
|
||||
lead_id NOT FOUND ← umbenannt ✓
|
||||
inquiry_id EXISTS ← neu ✓
|
||||
|
||||
Eloquent-Queries:
|
||||
Contact::count() = 19.156 (ohne merged)
|
||||
Customer::count() = 22.283 (alle, inkl. merged — Legacy-Model)
|
||||
Lead::count() = 19.543
|
||||
Booking::count() = 10.648
|
||||
Booking->inquiry_id funktioniert
|
||||
Booking->lead() Relation mit expl. FK inquiry_id funktioniert
|
||||
```
|
||||
|
||||
### Smoke-Test-Findings 2026-04-17 (Test-Durchlauf nach Phase-2-Restore)
|
||||
|
||||
Während der UI-Smoke-Tests auf `mein.sterntours.test` traten drei unterschiedliche Bug-Klassen auf, alle auf Test gefixt und in die Phase-2-Deploy-Liste aufgenommen.
|
||||
|
||||
**1. Eloquent-Relation ohne expliziten FK — `Lead::bookings()`**
|
||||
- Fehler: `/leads` → `Column not found: 'booking.lead_id'`.
|
||||
- Ursache: `$this->hasMany(Booking::class)` ohne 2. Argument ⇒ Laravel leitet den FK aus dem Model-Namen ab (`Lead` → `lead_id`), der nach dem Rename nicht mehr existiert.
|
||||
- Fix: `app/Models/Lead.php` Zeile 249–253: `$this->hasMany(Booking::class, 'inquiry_id')`.
|
||||
|
||||
**2. Hartcodierte Tabellenqualifier in `orderColumn`-SQL-Strings + Blade-Column-`name:`**
|
||||
- Fehler A: `/contacts` → `Column not found: 'customer.id' in 'order clause'`. Behoben durch SQL-Umstellung im Controller — blieb aber bestehen, weil Yajra bei fehlendem `orderColumn`-Match den `name:`-String aus der Blade-Column-Definition als raw SQL verwendet.
|
||||
- Fehler B (gleiche Ursache, 2. Runde): `/contacts` → derselbe Fehler trotz Controller-Fix. Root Cause: In `resources/views/contact/index.blade.php` Zeile 188 stand `name: 'customer.id'`. Yajra matched `orderColumn()` auf diesen `name:`-String — mein erster Fix mit `orderColumn('id', …)` hat also nicht getroffen.
|
||||
- Ursache zusammengefasst: Yajra-DataTables-`orderColumn()` bekommt als 2. Argument eine Raw-SQL-Expression; **und** der `name:`-Wert einer DataTable-Column muss exakt zum 1. Argument von `orderColumn()` / `filterColumn()` passen — sonst fällt Yajra zurück auf "Name wörtlich als SQL einsetzen".
|
||||
- Fix in 5 Dateien:
|
||||
- `app/Http/Controllers/ContactController.php` — `orderColumn('contacts.id' / 'deleted_at', …)`, `filterColumn('contacts.id', …)`
|
||||
- `resources/views/contact/index.blade.php` — Column-Name `customer.id` → `contacts.id`
|
||||
- `app/Http/Controllers/Admin/ReportController.php` — 2 Blöcke (`customer.firstname $1` → `contacts.firstname $1` etc.)
|
||||
- `app/Http/Controllers/Admin/ReportBookingController.php` — 2 Blöcke
|
||||
- `app/Http/Controllers/Admin/ReportLeadsController.php` — `$orderByNum`-Array
|
||||
- Bewusst nicht angefasst: die 1. Argumente von `addColumn` / `rawColumns` (`customer.fullName`, `lead.status_id`) — das sind reine DataTables-Frontend-Identifier, die Yajra über den Eloquent-Relation-Mechanismus auflöst (Model `Customer`/`Lead` → `$table = 'contacts'`/`'inquiries'`), und die 3-teiligen `booking.customer.firstname`-Strings in `ReportController.php` und `ReportProviderController.php` (Yajra-Relation-Notation bzw. seit jeher kein gültiges SQL, durch Phase 2 nicht schlimmer).
|
||||
|
||||
**3. Strict-Type-Regression aus Phase 1 (Laravel-10/PHP-8) — zwei Varianten**
|
||||
- Fehler A: `App\Services\Booking::getCustomerMailName(): Argument #2 ($mailDirId) must be of type int, null given` in `modal-show-mail-inner.blade.php` Zeile 92.
|
||||
- Fehler B: `App\Services\Booking::setOutputDirs(): Argument #2 ($subdir) must be of type string, null given` in derselben kompilierten View, Zeile 101.
|
||||
- Ursache: Blade-Views übergeben `$mail_dir_id` bzw. `$mail_sdir_id` direkt aus DB-Feldern, die nullable sein können (Entwurfs-Mails, Top-Level-Ordner, Mails ohne Subdir). Unter Laravel 8 / PHP 7 wurde `null` stillschweigend zu `0` / `""` gecastet; PHP 8 strict types verweigern das.
|
||||
- Fix in drei Dateien:
|
||||
- `app/Services/Booking.php` — `getCustomerMailName()`, `getCustomerMailEmails()`: `int $mailDirId` → `?int`; `setOutputDirs()`: `string $subdir` → `?string`.
|
||||
- `app/Services/Lead.php` — identische Änderungen (symmetrische API).
|
||||
- `app/Services/MailDirService.php` — `getCustomerMailName()`, `getCustomerMailEmails()`, `resolveModel()` auf `?int`; `setOutputDir()` auf `?string`; in `resolveModel()` zusätzlich ein früher `return null`-Guard.
|
||||
- Das ist **fachlich ein Phase-1-Bug**, der nichts mit Customer→Contacts zu tun hat. Wir deployen ihn trotzdem zusammen mit Phase 2, weil die Services keine DB-Abhängigkeit haben und ein separates Wartungsfenster unnötig wäre.
|
||||
|
||||
### Was in Phase 1 umgesetzt wurde
|
||||
|
||||
|
|
@ -178,33 +238,46 @@ php artisan migrate:rollback --path=database/migrations/2025_04_15_100001_phase1
|
|||
- Routen-Namen (`lead_detail`, `lead_index`) — bleiben als Aliase, um Links in Views/Mails/Logs nicht zu brechen.
|
||||
|
||||
### Schritt 1: Migrationen einspielen
|
||||
|
||||
**Auf Test (2026-04-17 erfolgreich durchgeführt, Batch 27–29):**
|
||||
```bash
|
||||
# Backup erstellen!
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
|
||||
php artisan migrate --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
|
||||
```
|
||||
|
||||
> **Wichtig — `--path` verwenden!** Ohne `--path` würde `php artisan migrate` auch die noch im Workspace liegenden Phase-3-, Phase-4- und Offers-Migrationen ausführen, die noch nicht deployreif sind. Einzeln per `--path` hält den Deploy auf exakt diese drei Dateien beschränkt.
|
||||
|
||||
**Auf Live:** Ablauf siehe [phase-2-live-deploy.md](phase-2-live-deploy.md). Der Live-Deploy muss innerhalb eines Wartungsfensters passieren, weil DB und Code atomar zusammengehen müssen.
|
||||
|
||||
### Ergebnis-Prüfung
|
||||
```sql
|
||||
-- Tabellen vorhanden?
|
||||
SHOW TABLES LIKE 'contacts';
|
||||
SHOW TABLES LIKE 'inquiries';
|
||||
-- Spalte umbenannt?
|
||||
SHOW COLUMNS FROM booking LIKE 'inquiry_id';
|
||||
-- FK vorhanden?
|
||||
SELECT * FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_NAME = 'booking' AND COLUMN_NAME = 'inquiry_id';
|
||||
```
|
||||
|
||||
Oder via Laravel-Tinker:
|
||||
```bash
|
||||
php artisan tinker --execute="
|
||||
echo Schema::hasTable('contacts') ? 'contacts OK' : 'contacts FEHLT'; echo PHP_EOL;
|
||||
echo Schema::hasTable('inquiries') ? 'inquiries OK' : 'inquiries FEHLT'; echo PHP_EOL;
|
||||
echo Schema::hasColumn('booking', 'inquiry_id') ? 'booking.inquiry_id OK' : 'FEHLT'; echo PHP_EOL;
|
||||
"
|
||||
```
|
||||
|
||||
### Rollback Phase 2
|
||||
```bash
|
||||
# In umgekehrter Reihenfolge
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200003_phase2_rename_booking_lead_id_to_inquiry_id.php --force
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200002_phase2_rename_lead_to_inquiries.php --force
|
||||
php artisan migrate:rollback --path=database/migrations/2025_04_15_200001_phase2_rename_customer_to_contacts.php --force
|
||||
```
|
||||
|
||||
> **Wichtig:** Rollback MUSS zusammen mit Code-Revert passieren (Code erwartet sonst die falschen Tabellennamen). Details im [phase-2-live-deploy.md](phase-2-live-deploy.md#rollback-plan-falls-phase-2-auf-live-scheitert).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Participants konsolidieren
|
||||
|
|
|
|||
|
|
@ -121,6 +121,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$priceTotalStored = $booking->getPriceTotalRaw();
|
||||
$priceTotalComputed = round((float) $booking->getPriceRaw() + (float) $booking->getServiceTotal(true), 2);
|
||||
@endphp
|
||||
<div class="col-12">
|
||||
<p class="small text-muted border rounded px-3 py-2 mb-3 bg-light">
|
||||
<strong>price_total</strong> (gespeichert in DB):
|
||||
{{ \App\Services\Util::_number_format($priceTotalStored ?? 0) }} €
|
||||
<span class="mx-2">·</span>
|
||||
<strong>rechnerisch</strong> (Organisation + Vermittlung):
|
||||
{{ \App\Services\Util::_number_format($priceTotalComputed) }} €
|
||||
@if($booking->isCanceled() && $booking->getPriceCanceledRaw() !== null)
|
||||
<span class="mx-2">·</span>
|
||||
<strong>laut Storno-Logik</strong> (= Storno-Betrag <code>price_canceled</code>):
|
||||
{{ \App\Services\Util::_number_format((float) $booking->getPriceCanceledRaw()) }} €
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<div class="text-left mt-3">
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@
|
|||
},
|
||||
{
|
||||
data: 'id',
|
||||
name: 'customer.id'
|
||||
name: 'contacts.id'
|
||||
},
|
||||
{
|
||||
data: 'firstname',
|
||||
|
|
|
|||
18
resources/views/emails/exception.blade.php
Normal file
18
resources/views/emails/exception.blade.php
Normal 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>
|
||||
BIN
vendor.tar
Normal file
BIN
vendor.tar
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue