diff --git a/app/Console/Commands/BookingsRepairFromTravelBookings.php b/app/Console/Commands/BookingsRepairFromTravelBookings.php new file mode 100644 index 0000000..e0b6234 --- /dev/null +++ b/app/Console/Commands/BookingsRepairFromTravelBookings.php @@ -0,0 +1,280 @@ + Pfad in travel_booking.selected_travel (Array notation). + * NULL-Pfade werden durch spezielle Closure-Handler behandelt. + */ + private const FIELD_MAP = [ + 'title' => ['travel_title'], + 'travel_number' => ['travel_number'], + 'travel_country_id' => ['travel_country_id', 0], + 'travel_category_id' => ['travel_category_id'], + 'travelagenda_id' => ['travelagenda_id'], + 'travel_company_id' => ['travel_company_id'], + ]; + + public function handle(): int + { + $apply = (bool) $this->option('apply'); + $force = (bool) $this->option('force'); + $days = max(1, (int) $this->option('days')); + $tbIds = (array) $this->option('id'); + $bookingIds = (array) $this->option('booking-id'); + + $query = TravelBooking::query() + ->whereNotNull('crm_booking_id') + ->orderBy('id', 'desc'); + + if (!empty($tbIds)) { + $query->whereIn('id', $tbIds); + $scopeLabel = 'per --id=' . implode(',', $tbIds); + } elseif (!empty($bookingIds)) { + $query->whereIn('crm_booking_id', $bookingIds); + $scopeLabel = 'per --booking-id=' . implode(',', $bookingIds); + } else { + $query->where('created', '>=', Carbon::now()->subDays($days)); + $scopeLabel = "letzte {$days} Tage"; + } + + $total = (clone $query)->count(); + $this->line(sprintf('Prüfe %d verknüpfte(n) travel_booking-Eintrag/e (%s)…', $total, $scopeLabel)); + + if ($total === 0) { + $this->info('Keine passenden Einträge gefunden.'); + return self::SUCCESS; + } + + $plan = []; + $missingSource = []; + $missingTarget = []; + + $query->chunkById(200, function ($chunk) use (&$plan, &$missingSource, &$missingTarget, $force) { + $crmIds = $chunk->pluck('crm_booking_id')->filter()->all(); + if (empty($crmIds)) { + return; + } + + $bookings = Booking::whereIn('id', $crmIds)->get()->keyBy('id'); + + foreach ($chunk as $tb) { + $booking = $bookings->get($tb->crm_booking_id); + if (!$booking) { + $missingTarget[] = $tb; + continue; + } + + $source = $tb->selected_travel; + if (!is_array($source) || empty($source)) { + $missingSource[] = $tb; + continue; + } + + $diff = $this->computeDiff($booking, $source, $force); + if (!empty($diff)) { + $plan[] = [ + 'tb' => $tb, + 'booking' => $booking, + 'diff' => $diff, + ]; + } + } + }); + + $this->newLine(); + $this->info('Analyse:'); + $this->line(sprintf(' • Booking-Einträge mit reparierbaren Lücken: %d', count($plan))); + $this->line(sprintf(' • Ohne selected_travel-JSON (nicht reparierbar): %d', count($missingSource))); + $this->line(sprintf(' • CRM-Booking existiert nicht mehr (Orphan): %d', count($missingTarget))); + + if (!empty($missingSource)) { + $this->newLine(); + $this->warn('Folgende travel_bookings haben kein/leeres selected_travel — hier lässt sich nichts nachziehen:'); + foreach ($missingSource as $tb) { + $this->line(sprintf(' SKIP tb#%d → booking #%d', $tb->id, $tb->crm_booking_id)); + } + } + + if (!empty($missingTarget)) { + $this->newLine(); + $this->warn('Folgende travel_bookings verweisen auf ein nicht existierendes CRM-Booking:'); + foreach ($missingTarget as $tb) { + $this->line(sprintf(' ORPHAN tb#%d → booking #%d fehlt', $tb->id, $tb->crm_booking_id)); + } + } + + if (empty($plan)) { + $this->newLine(); + $this->info('Keine Reparaturen nötig — alle Programm-Felder sind synchron.'); + return self::SUCCESS; + } + + $this->newLine(); + $this->line('Geplante Änderungen:'); + foreach ($plan as $item) { + $tb = $item['tb']; + $booking = $item['booking']; + $this->line(sprintf(' booking #%d (tb#%d, %s):', $booking->id, $tb->id, $booking->title ?: '—')); + foreach ($item['diff'] as $field => $change) { + $this->line(sprintf( + ' %-20s %s → %s', + $field, + $this->fmt($change['old']), + $this->fmt($change['new']) + )); + } + } + + if (!$apply) { + $this->newLine(); + $this->warn('Dry-Run — keine Änderungen. Mit --apply ausführen, um die Werte zu schreiben.'); + if (!$force) { + $this->line('Hinweis: Nur leere Felder werden gefüllt. Mit --force auch bestehende Werte überschreiben.'); + } + return self::SUCCESS; + } + + $this->newLine(); + $this->info('Schreibe Änderungen…'); + + $ok = 0; + $fail = 0; + foreach ($plan as $item) { + $booking = $item['booking']; + try { + DB::connection('mysql')->beginTransaction(); + foreach ($item['diff'] as $field => $change) { + $booking->{$field} = $change['new']; + } + $booking->save(); + DB::connection('mysql')->commit(); + $ok++; + $this->info(sprintf(' ✓ booking #%d aktualisiert (%d Feld/er)', $booking->id, count($item['diff']))); + } catch (Throwable $e) { + if (DB::connection('mysql')->transactionLevel() > 0) { + DB::connection('mysql')->rollBack(); + } + $fail++; + $this->error(sprintf(' ✗ booking #%d FAILED: %s', $booking->id, $e->getMessage())); + } + } + + $this->newLine(); + $this->info(sprintf('Fertig. Aktualisiert: %d Fehlgeschlagen: %d', $ok, $fail)); + + return $fail > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * Ermittelt, welche Felder sich ändern würden. + * + * @return array + */ + private function computeDiff(Booking $booking, array $source, bool $force): array + { + $diff = []; + + foreach (self::FIELD_MAP as $column => $path) { + $newValue = $this->resolvePath($source, $path); + + if ($newValue === null || $newValue === '') { + continue; + } + + $currentValue = $booking->{$column} ?? null; + + if (!$force && $this->isFilled($currentValue)) { + continue; + } + + if ((string) $currentValue === (string) $newValue) { + continue; + } + + $diff[$column] = [ + 'old' => $currentValue, + 'new' => $newValue, + ]; + } + + return $diff; + } + + /** + * Folgt einem Array-Pfad (z. B. ['travel_country_id', 0]) im Source-Array. + */ + private function resolvePath(array $source, array $path) + { + $cursor = $source; + foreach ($path as $key) { + if (!is_array($cursor) || !array_key_exists($key, $cursor)) { + return null; + } + $cursor = $cursor[$key]; + } + return $cursor; + } + + private function isFilled($value): bool + { + return $value !== null && $value !== '' && $value !== 0 && $value !== '0'; + } + + private function fmt($value): string + { + if ($value === null) return ''; + if ($value === '') return '""'; + return (string) $value; + } +} diff --git a/app/Console/Commands/BookingsResyncFromTravelBookings.php b/app/Console/Commands/BookingsResyncFromTravelBookings.php new file mode 100644 index 0000000..5362729 --- /dev/null +++ b/app/Console/Commands/BookingsResyncFromTravelBookings.php @@ -0,0 +1,172 @@ + --apply` + */ +class BookingsResyncFromTravelBookings extends Command +{ + protected $signature = 'bookings:resync-from-travel-bookings + {--days=14 : Zeitfenster in Tagen (auf travel_booking.created); ignoriert bei --id} + {--id=* : Konkrete travel_booking.id(s) prüfen/importieren (überschreibt --days)} + {--apply : Fehlende Imports wirklich ausführen (ohne Flag: nur Dry-Run)} + {--include-orphans : Zusätzlich prüfen, ob das verknüpfte CRM-Booking noch existiert}'; + + protected $description = 'Gleicht travel_booking (mysql_stern) mit booking (CRM) ab und zieht fehlende API-Imports nach.'; + + public function handle(): int + { + $apply = (bool) $this->option('apply'); + $ids = (array) $this->option('id'); + $days = max(1, (int) $this->option('days')); + $includeOrphans = (bool) $this->option('include-orphans'); + + $query = TravelBooking::query()->orderBy('id', 'desc'); + if (!empty($ids)) { + $query->whereIn('id', $ids); + $scopeLabel = 'per --id=' . implode(',', $ids); + } else { + $query->where('created', '>=', Carbon::now()->subDays($days)); + $scopeLabel = "letzte {$days} Tage"; + } + + $total = (clone $query)->count(); + $this->line(sprintf('Prüfe %d travel_booking-Eintrag/e (%s)…', $total, $scopeLabel)); + + if ($total === 0) { + $this->info('Keine Einträge im Scope gefunden.'); + return self::SUCCESS; + } + + $missing = []; + $orphans = []; + + $query->chunkById(200, function ($chunk) use (&$missing, &$orphans, $includeOrphans) { + foreach ($chunk as $tb) { + if (empty($tb->crm_booking_id)) { + $missing[] = $tb; + continue; + } + if ($includeOrphans && !Booking::whereKey($tb->crm_booking_id)->exists()) { + $orphans[] = $tb; + } + } + }); + + $this->newLine(); + $this->info('Ergebnis:'); + $this->line(sprintf(' • Nicht importiert (crm_booking_id IS NULL): %d', count($missing))); + if ($includeOrphans) { + $this->line(sprintf(' • Orphan (CRM-Booking fehlt): %d', count($orphans))); + } + + if (empty($missing) && empty($orphans)) { + $this->newLine(); + $this->info('Alles synchron — nichts zu tun.'); + return self::SUCCESS; + } + + if (!empty($missing)) { + $this->newLine(); + $this->line('Fehlende Imports:'); + foreach ($missing as $tb) { + $this->line(sprintf( + ' MISSING tb#%d %s %s <%s> %s → %s "%s"', + $tb->id, + $tb->first_name ?: '—', + $tb->last_name ?: '—', + $tb->email ?: '—', + optional($tb->selected_start_date)->format('Y-m-d') ?? '?', + optional($tb->selected_end_date)->format('Y-m-d') ?? '?', + $tb->program_name ?: ($tb->selected_travel['travel_title'] ?? '—') + )); + } + } + + if (!empty($orphans)) { + $this->newLine(); + $this->line('Orphans (CRM-Booking gelöscht/fehlt):'); + foreach ($orphans as $tb) { + $this->line(sprintf(' ORPHAN tb#%d → crm_booking_id=%d nicht vorhanden', $tb->id, $tb->crm_booking_id)); + } + $this->warn('Orphans werden NICHT automatisch neu importiert — bitte manuell prüfen (Daten evtl. bewusst gelöscht).'); + } + + if (!$apply) { + $this->newLine(); + $this->warn('Dry-Run — keine Änderungen. Mit --apply ausführen, um fehlende zu importieren.'); + return self::SUCCESS; + } + + if (empty($missing)) { + return self::SUCCESS; + } + + $this->newLine(); + $this->info('Starte Import der fehlenden Einträge…'); + + $ok = 0; + $fail = 0; + foreach ($missing as $tb) { + try { + DB::connection('mysql')->beginTransaction(); + $booking = BookingImport::importFrom($tb); + DB::connection('mysql')->commit(); + + $ok++; + $this->info(sprintf(' ✓ tb#%d → booking #%d', $tb->id, $booking->id)); + } catch (Throwable $e) { + if (DB::connection('mysql')->transactionLevel() > 0) { + DB::connection('mysql')->rollBack(); + } + $fail++; + $this->error(sprintf(' ✗ tb#%d FAILED: %s', $tb->id, $e->getMessage())); + } + } + + $this->newLine(); + $this->info(sprintf('Fertig. Erfolgreich: %d Fehlgeschlagen: %d', $ok, $fail)); + + return $fail > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Events/Offer/OfferAccepted.php b/app/Events/Offer/OfferAccepted.php new file mode 100644 index 0000000..1321f5c --- /dev/null +++ b/app/Events/Offer/OfferAccepted.php @@ -0,0 +1,27 @@ +plain_token` — wird NUR hier, + * beim Versand, in den Link eingebaut. Nach dem Event wird die Property + * wieder entsorgt. + */ +class OfferSent +{ + use Dispatchable; + + public function __construct( + public readonly Offer $offer, + public readonly OfferVersion $version, + public readonly OfferAccessToken $token, + /** @var array Betreff/Body/CC aus dem Versand-Modal. */ + public readonly array $mailData = [], + ) { + } +} diff --git a/app/Events/Offer/OfferWithdrawn.php b/app/Events/Offer/OfferWithdrawn.php new file mode 100644 index 0000000..e031e66 --- /dev/null +++ b/app/Events/Offer/OfferWithdrawn.php @@ -0,0 +1,22 @@ +offerId = $offer->id; + $this->currentStatus = $offer->status; + $this->expectedStatuses = $expected; + + parent::__construct(sprintf( + 'Offer #%d hat status=%s, Aktion „%s" erfordert status ∈ {%s}.', + $offer->id, + $offer->status, + $action, + implode(', ', $expected) + )); + } +} diff --git a/app/Exceptions/Offer/OfferNotEditableException.php b/app/Exceptions/Offer/OfferNotEditableException.php new file mode 100644 index 0000000..059d34e --- /dev/null +++ b/app/Exceptions/Offer/OfferNotEditableException.php @@ -0,0 +1,31 @@ +versionId = $v->id; + $this->currentStatus = $v->status; + + parent::__construct(sprintf( + 'OfferVersion #%d ist nicht mehr editierbar (status=%s). ' + . 'Aktion „%s" erfordert eine Draft-Version — nutze createNewVersion() / supersede().', + $v->id, + $v->status, + $attemptedAction + )); + } +} diff --git a/app/Http/Controllers/Admin/ReportBookingController.php b/app/Http/Controllers/Admin/ReportBookingController.php index 0c518e9..da7f01a 100644 --- a/app/Http/Controllers/Admin/ReportBookingController.php +++ b/app/Http/Controllers/Admin/ReportBookingController.php @@ -4,9 +4,9 @@ namespace App\Http\Controllers\Admin; use Request; -use Carbon\Carbon; use App\Models\Status; use App\Services\Util; +use App\Services\ReportDateFilterService; use App\Models\Booking; use App\Models\TravelCompany; use App\Http\Controllers\Controller; @@ -63,22 +63,22 @@ class ReportBookingController extends Controller $query->whereIn("travel_company_id", Request::get('filter_travel_company_id')); } - if(Request::get('filter_travel_date_from') != ""){ - $travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d"); + $travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report booking travel date from'); + if($travel_date_from){ $query->where("start_date", '>=', $travel_date_from); } - if(Request::get('filter_travel_date_to') != ""){ - $travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d"); + $travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report booking travel date to'); + if($travel_date_to){ $query->where("start_date", '<=', $travel_date_to); } - if(Request::get('filter_booking_date_from') != ""){ - $filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d"); + $filter_booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report booking booking date from'); + if($filter_booking_date_from){ $query->where("booking_date", '>=', $filter_booking_date_from); } - if(Request::get('filter_booking_date_to') != ""){ - $filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d"); - $query->where("booking_date", '<=', $filter_booking_date_from); + $filter_booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report booking booking date to'); + if($filter_booking_date_to){ + $query->where("booking_date", '<=', $filter_booking_date_to); } return $query; diff --git a/app/Http/Controllers/Admin/ReportController.php b/app/Http/Controllers/Admin/ReportController.php index 033d8b9..e73b839 100755 --- a/app/Http/Controllers/Admin/ReportController.php +++ b/app/Http/Controllers/Admin/ReportController.php @@ -6,9 +6,9 @@ use Request; use Response; use HTMLHelper; -use Carbon\Carbon; use App\Models\Status; use App\Services\Util; +use App\Services\ReportDateFilterService; use App\Models\Booking; use App\Models\TravelAgenda; use App\Models\TravelCompany; @@ -80,22 +80,22 @@ class ReportController extends Controller $query->whereIn("travel_company_id", Request::get('filter_travel_company_id')); } - if(Request::get('filter_travel_date_from') != ""){ - $travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d"); + $travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report travel date from'); + if($travel_date_from){ $query->where("start_date", '>=', $travel_date_from); } - if(Request::get('filter_travel_date_to') != ""){ - $travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d"); + $travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report travel date to'); + if($travel_date_to){ $query->where("start_date", '<=', $travel_date_to); } - if(Request::get('filter_booking_date_from') != ""){ - $travel_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d"); - $query->where("booking_date", '>=', $travel_date_from); + $booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report booking date from'); + if($booking_date_from){ + $query->where("booking_date", '>=', $booking_date_from); } - if(Request::get('filter_booking_date_to') != ""){ - $travel_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d"); - $query->where("booking_date", '<=', $travel_date_to); + $booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report booking date to'); + if($booking_date_to){ + $query->where("booking_date", '<=', $booking_date_to); } return $query; @@ -371,15 +371,15 @@ class ReportController extends Controller if(Request::get('filter_service_provider_id') != ""){ $query->where('service_provider_id', '=', Request::get('filter_service_provider_id')); } - if(Request::get('filter_travel_date_from') != ""){ - $query->whereHas('booking', function ($q) { - $travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d"); + $travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report provider travel date from'); + if($travel_date_from){ + $query->whereHas('booking', function ($q) use ($travel_date_from) { $q->where("start_date", '>=', $travel_date_from); }); } - if(Request::get('filter_travel_date_to') != ""){ - $query->whereHas('booking', function ($q) { - $travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d"); + $travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report provider travel date to'); + if($travel_date_to){ + $query->whereHas('booking', function ($q) use ($travel_date_to) { $q->where("start_date", '<=', $travel_date_to); }); } diff --git a/app/Http/Controllers/Admin/ReportFewoController.php b/app/Http/Controllers/Admin/ReportFewoController.php index 3a91a51..be6d0d0 100644 --- a/app/Http/Controllers/Admin/ReportFewoController.php +++ b/app/Http/Controllers/Admin/ReportFewoController.php @@ -6,9 +6,9 @@ use Request; use Response; use HTMLHelper; -use Carbon\Carbon; use App\Models\Status; use App\Services\Util; +use App\Services\ReportDateFilterService; use App\Models\Booking; use App\Models\FewoLodging; use App\Models\TravelAgenda; @@ -52,21 +52,21 @@ class ReportFewoController extends Controller $query->where('fewo_lodging_id', '=', Request::get('filter_option_fewo_id')); } - if(Request::get('filter_date_from') != ""){ - $date_from = Carbon::parse(Request::get('filter_date_from'))->format("Y-m-d"); + $date_from = ReportDateFilterService::parseDate(Request::get('filter_date_from'), 'report fewo date from'); + if($date_from){ $query->where("from_date", '>=', $date_from); } - if(Request::get('filter_date_to') != ""){ - $date_to = Carbon::parse(Request::get('filter_date_to'))->format("Y-m-d"); + $date_to = ReportDateFilterService::parseDate(Request::get('filter_date_to'), 'report fewo date to'); + if($date_to){ $query->where("from_date", '<=', $date_to); } - if(Request::get('filter_booking_date_from') != ""){ - $booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d"); + $booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report fewo booking date from'); + if($booking_date_from){ $query->where("booking_date", '>=', $booking_date_from); } - if(Request::get('filter_booking_date_to') != ""){ - $booking_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d"); + $booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report fewo booking date to'); + if($booking_date_to){ $query->where("booking_date", '<=', $booking_date_to); } diff --git a/app/Http/Controllers/Admin/ReportLeadsController.php b/app/Http/Controllers/Admin/ReportLeadsController.php index 232ddee..449f212 100644 --- a/app/Http/Controllers/Admin/ReportLeadsController.php +++ b/app/Http/Controllers/Admin/ReportLeadsController.php @@ -10,6 +10,7 @@ use Carbon\Carbon; use App\Models\Lead; use App\Models\Status; use App\Services\Util; +use App\Services\ReportDateFilterService; use App\Models\Booking; use App\Models\LeadMail; use App\Models\FewoLodging; @@ -57,12 +58,12 @@ class ReportLeadsController extends Controller $query->where('...?', '=', Request::get('filter_option_leads_id')); } */ - if(Request::get('filter_date_from') != ""){ - $date_from = Carbon::parse(Request::get('filter_date_from'))->format("Y-m-d"); + $date_from = ReportDateFilterService::parseDate(Request::get('filter_date_from'), 'report leads date from'); + if($date_from){ $query->where("request_date", '>=', $date_from); } - if(Request::get('filter_date_to') != ""){ - $date_to = Carbon::parse(Request::get('filter_date_to'))->format("Y-m-d"); + $date_to = ReportDateFilterService::parseDate(Request::get('filter_date_to'), 'report leads date to'); + if($date_to){ $query->where("request_date", '<=', $date_to); } return $query; diff --git a/app/Http/Controllers/Admin/ReportProviderController.php b/app/Http/Controllers/Admin/ReportProviderController.php index d9dbf9c..c39354b 100644 --- a/app/Http/Controllers/Admin/ReportProviderController.php +++ b/app/Http/Controllers/Admin/ReportProviderController.php @@ -4,9 +4,9 @@ namespace App\Http\Controllers\Admin; use Request; -use Carbon\Carbon; use App\Models\Status; use App\Services\Util; +use App\Services\ReportDateFilterService; use App\Models\TravelCompany; use App\Models\ServiceProvider; use App\Http\Controllers\Controller; @@ -63,28 +63,28 @@ class ReportProviderController extends Controller if(Request::get('filter_service_provider_id') != ""){ $query->where('service_provider_id', '=', Request::get('filter_service_provider_id')); } - if(Request::get('filter_travel_date_from') != ""){ - $query->whereHas('booking', function ($q) { - $travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d"); + $travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report provider travel date from'); + if($travel_date_from){ + $query->whereHas('booking', function ($q) use ($travel_date_from) { $q->where("start_date", '>=', $travel_date_from); }); } - if(Request::get('filter_travel_date_to') != ""){ - $query->whereHas('booking', function ($q) { - $travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d"); + $travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report provider travel date to'); + if($travel_date_to){ + $query->whereHas('booking', function ($q) use ($travel_date_to) { $q->where("start_date", '<=', $travel_date_to); }); } - if(Request::get('filter_booking_date_from') != ""){ - $query->whereHas('booking', function ($q) { - $filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d"); + $filter_booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report provider booking date from'); + if($filter_booking_date_from){ + $query->whereHas('booking', function ($q) use ($filter_booking_date_from) { $q->where("booking_date", '>=', $filter_booking_date_from); }); } - if(Request::get('filter_booking_date_to') != ""){ - $query->whereHas('booking', function ($q) { - $filter_booking_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d"); + $filter_booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report provider booking date to'); + if($filter_booking_date_to){ + $query->whereHas('booking', function ($q) use ($filter_booking_date_to) { $q->where("booking_date", '<=', $filter_booking_date_to); }); } diff --git a/app/Http/Controllers/OfferController.php b/app/Http/Controllers/OfferController.php new file mode 100644 index 0000000..b8001ca --- /dev/null +++ b/app/Http/Controllers/OfferController.php @@ -0,0 +1,184 @@ +middleware(['admin', '2fa']); + } + + // ── Permissions: offers-r = Routing (Lesen), fein: offers-w, offers-send, offers-accept + + public function hasOfferPermission(string $key): bool + { + $user = auth()->user(); + if (! $user) { + return false; + } + + return $user->isPermission($key); + } + + public function assertOfferPermission(string $key): void + { + if (! $this->hasOfferPermission($key)) { + abort(Response::HTTP_FORBIDDEN, 'Fehlende Berechtigung: ' . $key); + } + } + + public function index($step = false) + { + return view('offer.index', [ + 'step' => $step, + ]); + } + + public function getOffers() + { + $query = Offer::query() + ->with(['createdBy', 'contact']) + ->select('offers.*'); + + return \DataTables::eloquent($query) + ->addColumn('action_edit', function (Offer $offer) { + return '' + . ''; + }) + ->addColumn('id', function (Offer $offer) { + return '' + . (int) $offer->id . ''; + }) + ->addColumn('offer_number', function (Offer $offer) { + return e($offer->offer_number); + }) + ->addColumn('contact_name', function (Offer $offer) { + $c = $offer->contact; + if (! $c) { + return ''; + } + return e(trim(($c->firstname ?? '') . ' ' . ($c->name ?? ''))); + }) + ->addColumn('status_badge', function (Offer $offer) { + return '' . e($offer->status) . ''; + }) + ->addColumn('created_name', function (Offer $offer) { + return e($offer->createdBy->name ?? ''); + }) + ->addColumn('created_at_fmt', function (Offer $offer) { + if (! $offer->created_at) { + return ''; + } + return e($offer->created_at->format(\Util::formatDateTimeDB())); + }) + ->orderColumn('id', 'offers.id $1') + ->orderColumn('offer_number', 'offers.offer_number $1') + ->rawColumns(['action_edit', 'id', 'status_badge']) + ->make(true); + } + + public function detail($id) + { + if ($id === 'new') { + abort(404, 'Anlage über Modal folgt in B2.'); + } + $offer = Offer::query() + ->with(['currentVersion.items', 'contact', 'inquiry', 'createdBy']) + ->findOrFail($id); + + return view('offer.detail', [ + 'offer' => $offer, + 'id' => $offer->id, + ]); + } + + public function store(Request $request, $id) + { + $this->assertOfferPermission('offers-w'); + if (! $request->has('action')) { + abort(403, 'keine Action'); + } + // B3: Angebots-Editor, Autosave, … + \Session::flash('alert-info', 'Speichern (Stub A6) — B3 liefert die Logik.'); + + return redirect()->to(route('offer_detail', [$id]) . (string) $request->get('fragment', '')); + } + + public function loadModal(Request $request) + { + $data = $request->all(); + $html = ''; + if (($data['action'] ?? null) === 'modal-new-offer') { + $html = view('offer.modal_new_offer_stub', ['data' => $data])->render(); + } + if ($request->ajax() || $request->wantsJson()) { + return response()->json(['html' => $html, 'response' => $data]); + } + return response($html); + } + + public function action(Request $request, $action, $id = null) + { + $wActions = [ + 'create', 'update', 'supersede', 'duplicate', 'delete-file', + ]; + $sendActions = ['send', 'resend']; + $acceptActions = ['markAccepted', 'markDeclined', 'withdraw', 'mark_accepted', 'mark_declined']; + if (in_array($action, $wActions, true)) { + $this->assertOfferPermission('offers-w'); + } + if (in_array($action, $sendActions, true)) { + $this->assertOfferPermission('offers-send'); + } + if (in_array($action, $acceptActions, true)) { + $this->assertOfferPermission('offers-accept'); + } + $any = array_merge($wActions, $sendActions, $acceptActions); + if (! in_array($action, $any, true)) { + $this->assertOfferPermission('offers-w'); + } + if ($id === null) { + abort(404); + } + if ($id !== 'new') { + $offer = Offer::find($id); + if (! $offer) { + abort(404); + } + } else { + $offer = null; + } + \Session::flash('alert-info', 'Aktion «' . e($action) . '» (Stub A6) — siehe B2/B3.'); + + if ($offer) { + return redirect()->route('offer_detail', [$offer->id]); + } + return redirect()->route('offers'); + } + + public function delete($id, $del = null) + { + $this->assertOfferPermission('offers-w'); + \Session::flash('alert-info', 'Löschen (Stub) — B9 liefert Soft-Delete-Logik.'); + + return redirect()->route('offers'); + } + + /** + * PDF-Generierung: Stub (Ticket B5 liefert den Stream). + */ + public function pdf($versionId, $do = null) + { + $version = OfferVersion::query()->with('offer')->findOrFail($versionId); + return response('PDF-Generator folgt in B5 (OfferVersion #' . (int) $version->id . ').', 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + ]); + } +} diff --git a/app/Http/Controllers/OfferFileController.php b/app/Http/Controllers/OfferFileController.php new file mode 100644 index 0000000..2d2accc --- /dev/null +++ b/app/Http/Controllers/OfferFileController.php @@ -0,0 +1,63 @@ +middleware(['admin', '2fa']); + } + + public function upload(Request $request, $versionId) + { + if (! auth()->user()->isPermission('offers-w')) { + abort(403, 'Fehlende Berechtigung: offers-w'); + } + $version = OfferVersion::findOrFail($versionId); + if (! $version->isEditable()) { + return response()->json([ + 'error' => true, + 'message' => 'Diese Version ist nicht mehr bearbeitbar.', + 'code' => 403, + ], 403); + } + $repo = new OfferFileRepository(new OfferFile()); + $repo->_set('disk', 'offer'); + $repo->_set('offer_version_id', (int) $versionId); + $repo->_set('dir', '/files/' . date('Y/m') . '/'); + $repo->_set('identifier', 'offer'); + if ($request->has('include_in_pdf')) { + $repo->_set('include_in_pdf', filter_var($request->input('include_in_pdf'), FILTER_VALIDATE_BOOL)); + } + + return $repo->uploadFile($request->all()); + } + + public function delete($id) + { + if (! auth()->user()->isPermission('offers-w')) { + abort(403, 'Fehlende Berechtigung: offers-w'); + } + $file = OfferFile::query()->with('offerVersion')->findOrFail($id); + if ($file->offerVersion && ! $file->offerVersion->isEditable()) { + \Session::flash('alert-warning', 'Datei gehört zu einer gesperrten Version.'); + + return redirect()->back(); + } + $repo = new OfferFileRepository($file); + $repo->_set('disk', 'offer'); + if ($repo->delete()) { + \Session::flash('alert-success', 'Datei gelöscht'); + } else { + \Session::flash('alert-warning', 'Löschen fehlgeschlagen'); + } + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/OfferTemplateController.php b/app/Http/Controllers/OfferTemplateController.php new file mode 100644 index 0000000..7dd189e --- /dev/null +++ b/app/Http/Controllers/OfferTemplateController.php @@ -0,0 +1,60 @@ +middleware(['admin', '2fa']); + } + + public function index() + { + return view('offer_template.index', [ + 'templates' => OfferTemplate::query()->orderBy('name')->get(), + ]); + } + + public function detail($id) + { + if ($id === 'new') { + $template = new OfferTemplate(); + $id = 'new'; + } else { + $template = OfferTemplate::findOrFail($id); + $id = $template->id; + } + return view('offer_template.detail', [ + 'template' => $template, + 'id' => $id, + ]); + } + + public function store(Request $request, $id = null) + { + if (! $request->has('action')) { + abort(403, 'keine Action'); + } + \Session::flash('alert-info', 'Vorlagen speichern (Stub A6) — C1 liefert die Logik.'); + + if ($id === 'new' || $id === null) { + return redirect()->route('offer_template_detail', ['id' => 'new']); + } + $template = OfferTemplate::findOrFail($id); + + return redirect()->route('offer_template_detail', ['id' => $template->id]); + } + + public function delete($id) + { + $template = OfferTemplate::findOrFail($id); + $template->delete(); + \Session::flash('alert-success', 'Vorlage gelöscht.'); + + return redirect()->route('offer_templates'); + } +} diff --git a/app/Http/Controllers/TravelUserBookingFewoController.php b/app/Http/Controllers/TravelUserBookingFewoController.php index f2089dd..42ca4c9 100755 --- a/app/Http/Controllers/TravelUserBookingFewoController.php +++ b/app/Http/Controllers/TravelUserBookingFewoController.php @@ -128,7 +128,11 @@ class TravelUserBookingFewoController extends Controller return back()->withRequest(Request::all())->withErrors($ret['error']); } if($ret['success'] == true){ - $this->userBookingFewoRepo->createTravelInfoPDF($id, $data['info_mail_text']); + $createResult = $this->userBookingFewoRepo->createTravelInfoPDF($id, $data['info_mail_text']); + if($createResult === false){ + \Session()->flash('alert-error', __('Anreiseinfo konnte nicht erstellt werden: Es fehlt eine Rechnungsnummer.')); + return redirect(route('travel_user_booking_fewo_detail', [$ret['id']])); + } \Session()->flash('alert-success', __('Anreiseinfo wurde erstellt/gespeichert.')); return redirect(route('travel_user_booking_fewo_detail', [$ret['id']])); diff --git a/app/Http/Requests/Offer/OfferTemplateRequest.php b/app/Http/Requests/Offer/OfferTemplateRequest.php new file mode 100644 index 0000000..c53b172 --- /dev/null +++ b/app/Http/Requests/Offer/OfferTemplateRequest.php @@ -0,0 +1,104 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'branch_id' => 'nullable|integer|exists:branch,id', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:5000', + + 'default_headline' => 'nullable|string|max:500', + 'default_intro' => 'nullable|string|max:65000', + 'default_itinerary' => 'nullable|string|max:65000', + 'default_closing' => 'nullable|string|max:65000', + + 'default_items' => 'nullable|array|max:200', + 'default_items.*' => 'array', + 'default_items.*.type' => ['required', Rule::in(OfferItem::TYPES)], + 'default_items.*.title' => 'required|string|max:1000', + 'default_items.*.description' => 'nullable|string|max:20000', + 'default_items.*.quantity' => 'required|integer|min:1', + 'default_items.*.price_per_unit' => 'required|numeric', + 'default_items.*.travel_program_id' => 'nullable|integer|min:0', + 'default_items.*.fewo_lodging_id' => 'nullable|integer|min:0', + 'default_items.*.metadata' => 'nullable|array', + + 'is_active' => 'nullable|boolean', + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $v) { + if ($v->fails()) { + return; + } + $rows = $this->input('default_items', []) ?? []; + foreach (array_values($rows) as $i => $row) { + if (! is_array($row)) { + continue; + } + $type = $row['type'] ?? null; + $p = $row['price_per_unit'] ?? null; + if ($type === null || $p === null) { + continue; + } + if (in_array($type, [OfferItem::TYPE_TRAVEL, OfferItem::TYPE_SERVICE, OfferItem::TYPE_OPTION, OfferItem::TYPE_INSURANCE, OfferItem::TYPE_CUSTOM], true)) { + if ((float) $p < 0) { + $v->errors()->add("default_items.$i.price_per_unit", 'Der Einzelpreis muss mindestens 0 sein (außer bei Rabatten).'); + } + } + } + }); + } + + protected function prepareForValidation(): void + { + if ($this->has('default_headline') && is_string($this->input('default_headline'))) { + $this->merge(['default_headline' => strip_tags($this->input('default_headline'))]); + } + $this->merge( + $this->sanitizeWysiwygInput([ + 'default_intro' => $this->input('default_intro'), + 'default_itinerary' => $this->input('default_itinerary'), + 'default_closing' => $this->input('default_closing'), + ]) + ); + } + + /** + * @param array $fields + * @return array + */ + protected function sanitizeWysiwygInput(array $fields): array + { + $allowed = '