Compare commits

...

2 commits

74 changed files with 4255 additions and 396 deletions

View file

@ -0,0 +1,280 @@
<?php
namespace App\Console\Commands;
use Throwable;
use Carbon\Carbon;
use App\Models\Booking;
use App\Models\TravelBooking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Repair: Programm-Felder im CRM-Booking aus travel_booking.selected_travel nachziehen.
*
* Hintergrund:
* BookingImport::importFrom() liest beim API-Import die Programm-Informationen
* aus travel_booking.selected_travel (JSON) und schreibt sie in booking. War
* dieses JSON zum Import-Zeitpunkt unvollständig (oder leer), fehlen im CRM
* einzelne Programm-Felder sichtbar z. B. als "Reiseprogramm fehlt" in der
* Buchungsanzeige. Das travel_booking-Backup enthält die Daten in der Regel
* trotzdem (bzw. wurde später vervollständigt), also können wir sie sauber
* nachziehen.
*
* Geprüfte / reparierbare Felder:
* booking.title selected_travel['travel_title']
* booking.travel_number selected_travel['travel_number']
* booking.travel_country_id selected_travel['travel_country_id'][0]
* booking.travel_category_id selected_travel['travel_category_id']
* booking.travelagenda_id selected_travel['travelagenda_id']
* booking.travel_company_id selected_travel['travel_company_id'] (Default 4)
*
* Strategie:
* Default: nur Felder ÜBERSCHREIBEN, die im Booking aktuell leer/NULL sind
* ( keine manuell gesetzten Werte werden zerstört).
* --force: setzt auch bereits gefüllte Felder auf den Source-Wert um.
* Default: Dry-Run. Nur mit --apply wird wirklich geschrieben.
*
* Beispiele:
* php artisan bookings:repair-from-travel-bookings
* php artisan bookings:repair-from-travel-bookings --days=30
* php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply
* php artisan bookings:repair-from-travel-bookings --id=12345 --apply --force
*/
class BookingsRepairFromTravelBookings extends Command
{
protected $signature = 'bookings:repair-from-travel-bookings
{--days=14 : Zeitfenster in Tagen (auf travel_booking.created); ignoriert bei --id/--booking-id}
{--id=* : Konkrete travel_booking.id(s)}
{--booking-id=* : Konkrete booking.id(s) (im CRM) mappt rückwärts auf travel_booking}
{--apply : Änderungen wirklich schreiben (ohne Flag: nur Dry-Run)}
{--force : Auch bereits gesetzte Booking-Felder überschreiben (Default: nur leere/NULL füllen)}';
protected $description = 'Gleicht programmbezogene Felder im CRM-Booking mit travel_booking.selected_travel ab und füllt fehlende Werte nach.';
/**
* Mapping: booking-Spalte => 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<string, array{old: mixed, new: mixed}>
*/
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 '<null>';
if ($value === '') return '""';
return (string) $value;
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace App\Console\Commands;
use Throwable;
use Carbon\Carbon;
use App\Models\Booking;
use App\Models\TravelBooking;
use App\Services\BookingImport;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Abgleich: travel_booking (DB mysql_stern, Web-Frontend-Backup)
* booking (DB mysql, CRM-Hauptsystem)
*
* Hintergrund:
* Das Web-Frontend schreibt jede Buchung zuerst in die Tabelle `travel_booking`
* (Connection `mysql_stern`) und ruft dann via API den Import-Endpunkt des CRM
* auf. Dieser legt Customer + Lead + Booking (+ Participants/Arrangements/Drafts)
* an und trägt zuletzt die neue `booking.id` in `travel_booking.crm_booking_id`
* zurück. Bricht der Import irgendwo vorher ab, fehlt entweder das CRM-Booking
* ganz oder es ist nur teilweise angelegt und `crm_booking_id` bleibt NULL.
*
* Was der Command macht:
* Listet `travel_booking`-Einträge der letzten N Tage, bei denen
* `crm_booking_id IS NULL` (primärer Fehlerfall).
* Optional: listet Orphans (crm_booking_id gesetzt, aber Booking existiert
* nicht mehr z. B. gelöscht) mit --include-orphans.
* Default ist Dry-Run nur mit --apply wird wirklich importiert.
* Jeder Import läuft in einer eigenen CRM-DB-Transaktion.
*
* Beispiele:
* php artisan bookings:resync-from-travel-bookings
* php artisan bookings:resync-from-travel-bookings --days=30
* php artisan bookings:resync-from-travel-bookings --apply
* php artisan bookings:resync-from-travel-bookings --id=12345 --id=12346 --apply
* php artisan bookings:resync-from-travel-bookings --include-orphans
*
* Einzelfall neu importieren (wenn Datensatz im CRM unvollständig ist,
* z. B. fehlendes Reiseprogramm):
* 1) Im CRM das unvollständige Booking löschen (oder gezielt merken).
* 2) In mysql_stern.travel_booking `crm_booking_id` für diesen Datensatz
* auf NULL zurücksetzen.
* 3) `php artisan bookings:resync-from-travel-bookings --id=<travel_booking.id> --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;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Events\Offer;
use App\Models\Offer;
use App\Models\OfferVersion;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Wird gefeuert, nachdem eine OfferVersion als `accepted` markiert wurde
* (durch Admin oder Kunden-Token-Link). Listener schreiben Audit-Logs,
* benachrichtigen das Team und aktivieren den Button „In Buchung übernehmen".
*/
class OfferAccepted
{
use Dispatchable;
public function __construct(
public readonly Offer $offer,
public readonly OfferVersion $version,
/** customer_link | admin | email */
public readonly string $via,
public readonly ?string $ip = null,
public readonly ?string $userAgent = null,
) {
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Events\Offer;
use App\Models\Offer;
use App\Models\OfferVersion;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Wird gefeuert, nachdem eine OfferVersion als `declined` markiert wurde.
* Listener schreiben Audit-Log + informieren das Team.
*/
class OfferDeclined
{
use Dispatchable;
public function __construct(
public readonly Offer $offer,
public readonly OfferVersion $version,
public readonly string $via,
public readonly ?string $reason = null,
) {
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Events\Offer;
use App\Models\Offer;
use App\Models\OfferAccessToken;
use App\Models\OfferVersion;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Wird gefeuert, nachdem eine OfferVersion status=sent gesetzt wurde und ein
* frischer Access-Token existiert. Listener (Ticket B6) versenden die Mail,
* schreiben das Kommunikations-Log und fügen den öffentlichen Link ein.
*
* Der Mail-Plaintext-Token liegt in `$token->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<string,mixed> Betreff/Body/CC aus dem Versand-Modal. */
public readonly array $mailData = [],
) {
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Events\Offer;
use App\Models\Offer;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Wird gefeuert, nachdem ein Offer durch den Admin zurückgezogen wurde
* (status=withdrawn, alle aktiven Tokens revoked). Listener schreiben
* Audit-Log + benachrichtigen ggf. den Kunden.
*/
class OfferWithdrawn
{
use Dispatchable;
public function __construct(
public readonly Offer $offer,
public readonly string $reason,
) {
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Exceptions\Offer;
use App\Models\Offer;
use DomainException;
/**
* Geworfen, wenn eine Aktion den Offer in einem Status verlangt, in dem er
* gerade nicht ist (z. B. accept auf einem Draft, convertToBooking auf nicht-accepted).
*/
class InvalidOfferStatusException extends DomainException
{
public readonly int $offerId;
public readonly string $currentStatus;
/** @var string[] */
public readonly array $expectedStatuses;
/**
* @param string[] $expected
*/
public function __construct(Offer $offer, array $expected, string $action)
{
$this->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)
));
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Exceptions\Offer;
use App\Models\OfferVersion;
use DomainException;
/**
* Geworfen, wenn eine nicht-Draft-Version verändert werden soll.
* Klar abgrenzbarer Typ zu {@see InvalidOfferStatusException}
* (dort: Offer.status passt nicht; hier: OfferVersion.status blockiert Edits).
*/
class OfferNotEditableException extends DomainException
{
public readonly int $versionId;
public readonly string $currentStatus;
public function __construct(OfferVersion $v, string $attemptedAction = 'ändern')
{
$this->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
));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -68,9 +68,24 @@ class CMSSidebarController extends Controller
$data['active'] = isset($data['active']) ? true : false;
$data['show_at'] = isset($data['show_at']) ? $data['show_at'] : null;
$widget->fill($data)->save();
$component = isset($data['component']) ? $data['component'] : null;
$widget->save();
if (in_array($component, [SidebarWidget::HOMEPAGE_PLANNABLE_TRIPS, SidebarWidget::HOMEPAGE_POPULAR_TRIPS])) {
$pageIds = isset($data['homepage_page_ids']) ? array_values(array_filter($data['homepage_page_ids'])) : [];
$newPageIds = isset($data['homepage_new_page_ids']) ? array_values(array_filter($data['homepage_new_page_ids'])) : [];
$data['html'] = json_encode([
'page_ids' => $pageIds,
'new_page_ids' => array_values(array_intersect($pageIds, $newPageIds)),
'new_badge_active' => false,
]);
} elseif ($component === SidebarWidget::NEWS_SIDEBAR_WIDGET && isset($data['homepage_news_limit'])) {
$data['html'] = json_encode([
'news_limit' => max(1, min(12, (int) $data['homepage_news_limit'])),
]);
}
$widget->fill($data)->save();
\Session()->flash('alert-save', '1');
return redirect(route('cms_sidebar_detail', [$widget->id]));

View file

@ -0,0 +1,184 @@
<?php
namespace App\Http\Controllers;
use App\Models\Offer;
use App\Models\OfferVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class OfferController extends Controller
{
public function __construct()
{
$this->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 '<a href="' . e(route('offer_detail', [$offer->id])) . '" class="btn icon-btn btn-sm btn-primary" title="Öffnen">'
. '<span class="fa fa-edit"></span></a>';
})
->addColumn('id', function (Offer $offer) {
return '<a data-order="' . (int) $offer->id . '" href="'
. e(route('offer_detail', [$offer->id])) . '" data-id="' . (int) $offer->id . '">'
. (int) $offer->id . '</a>';
})
->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 '<span class="badge badge-secondary">' . e($offer->status) . '</span>';
})
->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',
]);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Models\OfferFile;
use App\Models\OfferVersion;
use App\Repositories\OfferFileRepository;
use Illuminate\Http\Request;
class OfferFileController extends Controller
{
public function __construct()
{
$this->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();
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\OfferTemplate;
use Illuminate\Http\Request;
class OfferTemplateController extends Controller
{
public function __construct()
{
$this->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');
}
}

View file

@ -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']]));

View file

@ -0,0 +1,104 @@
<?php
namespace App\Http\Requests\Offer;
use App\Models\OfferItem;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class OfferTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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<string, mixed> $fields
* @return array<string, string|null>
*/
protected function sanitizeWysiwygInput(array $fields): array
{
$allowed = '<p><br><br/><strong><b><em><i><u><ul><ol><li><a><h1><h2><h3><h4><h5><h6><blockquote><span><div><table><thead><tbody><tr><th><td>';
$out = [];
foreach ($fields as $key => $val) {
if ($val === null) {
$out[$key] = $val;
continue;
}
if (! is_string($val)) {
$out[$key] = (string) $val;
continue;
}
$out[$key] = strip_tags($val, $allowed);
}
return $out;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Offer;
use Illuminate\Foundation\Http\FormRequest;
class SendOfferRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'subject' => 'required|string|max:500',
'body' => 'required|string|max:100000',
'cc' => 'nullable|string|max:2000',
'bcc' => 'nullable|string|max:2000',
'reply_to' => 'nullable|email|max:255',
'expires_at' => 'nullable|date|after:now',
];
}
public function messages(): array
{
return [
'subject.required' => 'Bitte einen Betreff angeben.',
'body.required' => 'Bitte den E-Mail-Text angeben.',
'expires_at.after' => 'Ablaufdatum muss in der Zukunft liegen.',
];
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Offer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreOfferRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'contact_id' => [
'nullable',
'integer',
Rule::exists('contacts', 'id'),
],
'inquiry_id' => [
'nullable',
'integer',
Rule::exists('inquiries', 'id'),
],
'template_id' => [
'nullable',
'integer',
Rule::exists('offer_templates', 'id')->whereNull('deleted_at'),
],
'booking_id' => [
'nullable',
'integer',
Rule::exists('booking', 'id'),
],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
if (! $v->fails() && $this->input('contact_id') === null && $this->input('inquiry_id') === null) {
$v->errors()->add('contact_id', 'Bitte wähle einen Kontakt oder eine Anfrage.');
}
});
}
public function messages(): array
{
return [
'contact_id.exists' => 'Der gewählte Kontakt existiert nicht.',
'inquiry_id.exists' => 'Die gewählte Anfrage existiert nicht.',
'template_id.exists' => 'Die gewählte Vorlage existiert (nicht) oder ist gelöscht.',
'booking_id.exists' => 'Die gewählte Buchung existiert nicht.',
];
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Http\Requests\Offer;
use App\Models\OfferItem;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateVersionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'headline' => 'nullable|string|max:500',
'intro_text' => 'nullable|string|max:65000',
'itinerary_text' => 'nullable|string|max:65000',
'closing_text' => 'nullable|string|max:65000',
'valid_until' => 'nullable|date|after_or_equal:today',
'template_id' => [
'nullable',
'integer',
Rule::exists('offer_templates', 'id')->whereNull('deleted_at'),
],
'template_document_ids' => 'nullable|array',
'template_document_ids.*' => 'integer',
'items' => 'nullable|array|max:200',
'items.*' => 'array',
'items.*.id' => 'nullable|integer|exists:offer_items,id',
'items.*.type' => ['required', Rule::in(OfferItem::TYPES)],
'items.*.title' => 'required|string|max:1000',
'items.*.description' => 'nullable|string|max:20000',
'items.*.quantity' => 'required|integer|min:1',
'items.*.price_per_unit' => 'required|numeric',
'items.*.travel_program_id' => 'nullable|integer|min:0',
'items.*.fewo_lodging_id' => 'nullable|integer|min:0',
'items.*.metadata' => 'nullable|array',
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
if ($v->fails()) {
return;
}
$items = $this->input('items', []);
foreach (array_values($items) 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("items.$i.price_per_unit", 'Der Einzelpreis muss mindestens 0 sein (außer bei Rabatten).');
}
}
}
});
}
protected function prepareForValidation(): void
{
if ($this->has('headline') && is_string($this->input('headline'))) {
$this->merge(['headline' => strip_tags($this->input('headline'))]);
}
$this->merge($this->sanitizeWysiwygFields([
'intro_text' => $this->input('intro_text'),
'itinerary_text' => $this->input('itinerary_text'),
'closing_text' => $this->input('closing_text'),
]));
}
/**
* Einfache WYSIWYG-Whitelist (TinyMCE/TipTap): gefährliche Tags raus, Basis-Formatierung behalten.
*
* @param array<string, mixed> $fields
* @return array<string, string>
*/
protected function sanitizeWysiwygFields(array $fields): array
{
$allowed = '<p><br><br/><strong><b><em><i><u><ul><ol><li><a><h1><h2><h3><h4><h5><h6><blockquote><span><div><table><thead><tbody><tr><th><td>';
$out = [];
foreach ($fields as $key => $val) {
if ($val === null) {
$out[$key] = $val;
continue;
}
if (! is_string($val)) {
$out[$key] = (string) $val;
continue;
}
$out[$key] = strip_tags($val, $allowed);
}
return $out;
}
}

View file

@ -51,10 +51,12 @@ class MailSendFeWoInfo extends Mailable
'greetings' => __('Best regards'),
'model' => $this->travel_user_booking_fewo,
]);
$message->attach($file1['path'], [
'as' => $file1['name'],
'mime' => $file1['mine'],
]);
if ($file1['path'] && is_file($file1['path'])) {
$message->attach($file1['path'], [
'as' => $file1['name'],
'mime' => $file1['mine'],
]);
}
foreach ($this->files as $file) {
$message->attach($file->getPath(),[

View file

@ -13,31 +13,31 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Angebot (Modul 6).
* Angebots-Kopfdatensatz (Modul 6 Offers).
*
* 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).
* Trägt Angebotsnummer + Beziehungen zu Kontakt / Anfrage / Buchung und
* zeigt via `current_version_id` auf die aktuell gültige OfferVersion.
* Die eigentlichen Inhalte (Texte, Positionen, Preise, PDF) liegen in
* `offer_versions`. Jede Änderung nach dem ersten Versand erzeugt eine
* neue Version (siehe OfferVersion::isEditable()).
*
* @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 int $id
* @property string $offer_number
* @property int $contact_id
* @property int|null $inquiry_id
* @property int|null $booking_id
* @property string $status draft|sent|accepted|declined|expired|withdrawn
* @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 Collection|OfferVersion[] $versions
* @property-read Collection|OfferAccessToken[] $accessTokens
* @property-read User $creator
* @property-read OfferVersion|null $currentVersion
* @property-read User $createdBy
*/
class Offer extends Model
{
@ -50,15 +50,13 @@ class Offer extends Model
public const STATUS_EXPIRED = 'expired';
public const STATUS_WITHDRAWN = 'withdrawn';
public const STATUSES = [
/** Stati, die als „nicht abgeschlossen" gelten (Offer läuft noch). */
public const OPEN_STATUSES = [
self::STATUS_DRAFT,
self::STATUS_SENT,
self::STATUS_ACCEPTED,
self::STATUS_DECLINED,
self::STATUS_EXPIRED,
self::STATUS_WITHDRAWN,
];
protected $connection = 'mysql';
protected $table = 'offers';
protected $fillable = [
@ -79,20 +77,30 @@ class Offer extends Model
'created_by' => 'int',
];
// ── Beziehungen ──────────────────────────────────────────────────────────
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
return $this->belongsTo(Contact::class, 'contact_id');
}
/**
* Die Anfrage (Phase 2 heißt die Tabelle `inquiries`, das Eloquent-Model
* ist weiterhin `Lead` Umbenennung des Models ist eigenes Ticket).
*/
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);
return $this->belongsTo(Booking::class, 'booking_id');
}
public function versions(): HasMany
{
return $this->hasMany(OfferVersion::class, 'offer_id')->orderBy('version_no');
}
public function currentVersion(): BelongsTo
@ -100,33 +108,41 @@ class Offer extends Model
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
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeStatus(Builder $q, string $status): Builder
// ── Scopes ───────────────────────────────────────────────────────────────
/** Nur noch nicht abgeschlossene Angebote (draft + sent). */
public function scopeOpen(Builder $query): Builder
{
return $q->where('status', $status);
return $query->whereIn('status', self::OPEN_STATUSES);
}
public function scopeOpen(Builder $q): Builder
public function scopeForContact(Builder $query, int $contactId): Builder
{
return $q->whereIn('status', [self::STATUS_DRAFT, self::STATUS_SENT]);
return $query->where('contact_id', $contactId);
}
/**
* @param string[] $stati
*/
public function scopeWithStatus(Builder $query, array $stati): Builder
{
return $query->whereIn('status', $stati);
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SENT], true);
return $this->status === self::STATUS_DRAFT;
}
public function isOpen(): bool
{
return in_array($this->status, self::OPEN_STATUSES, true);
}
}

View file

@ -10,33 +10,39 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
* Einmal-Token für den kundenseitigen Angebots-Freigabe-Link
* (`/angebot/{token}` siehe 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.
* In der Datenbank liegt nur der **SHA-256-Hash** des Tokens
* (`token_hash`), nicht der Klartext das minimiert das Risiko, wenn
* die DB einmal abgegriffen wird. Der Klartext-Token wird ausschließlich
* einmal von {@see self::generate()} zurückgegeben (transient in der
* Property `plain_token`) und dann in der versendeten Mail an den
* Kunden eingebettet.
*
* 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 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
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Offer $offer
* @property-read OfferVersion $offerVersion
*
* Transient (nicht persistent, nur direkt nach generate()):
* @property-read string|null $plain_token
*/
class OfferAccessToken extends Model
{
use HasFactory;
/** Anzahl der zufälligen Klartext-Zeichen (ergibt ≥ 48 bit Entropie deutlich). */
public const TOKEN_LENGTH = 64;
protected $connection = 'mysql';
protected $table = 'offer_access_tokens';
protected $fillable = [
@ -56,65 +62,85 @@ class OfferAccessToken extends Model
'revoked_at' => 'datetime',
];
/**
* Transient der ungehashte Token. Nur unmittelbar nach {@see generate()}
* gesetzt, wird weder gespeichert noch serialisiert.
*/
public ?string $plain_token = null;
// ── Beziehungen ──────────────────────────────────────────────────────────
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
return $this->belongsTo(Offer::class, 'offer_id');
}
public function version(): BelongsTo
public function offerVersion(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function scopeActive(Builder $q): Builder
// ── Scopes ───────────────────────────────────────────────────────────────
/**
* Nur Tokens, die aktuell wirklich verwendet werden können:
* nicht widerrufen, nicht abgelaufen.
*/
public function scopeActive(Builder $query): Builder
{
return $q->whereNull('revoked_at')
return $query
->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);
// ── Factories / Helper ───────────────────────────────────────────────────
/** @var self $token */
$token = self::create([
'offer_id' => $offer->id,
/**
* Erzeugt einen neuen Access-Token für eine Version. Der Klartext-Token
* wird einmalig zurückgegeben (`$result->plain_token`), die DB speichert
* nur den SHA-256-Hash.
*
* @param OfferVersion $version
* @param Carbon|null $expiresAt null kein Ablauf (bewusst auf Caller-Ebene entscheiden)
*/
public static function generate(OfferVersion $version, ?Carbon $expiresAt = null): self
{
$plain = Str::random(self::TOKEN_LENGTH);
$token = new self([
'offer_id' => $version->offer_id,
'offer_version_id' => $version->id,
'token_hash' => $hash,
'token_hash' => hash('sha256', $plain),
'expires_at' => $expiresAt,
]);
$token->save();
return ['plain' => $plain, 'token' => $token];
$token->plain_token = $plain;
return $token;
}
/**
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
* Findet einen aktiven Token anhand seines Klartext-Werts (Mail-Link).
* Gibt null zurück, wenn der Token unbekannt, widerrufen oder abgelaufen ist.
*/
public static function findByPlainToken(string $plain): ?self
public static function findActiveByPlainToken(string $plain): ?self
{
return self::where('token_hash', hash('sha256', $plain))->first();
$hash = hash('sha256', $plain);
return self::query()->active()->where('token_hash', $hash)->first();
}
public function isActive(): bool
/**
* Widerruft diesen Token er ist danach nicht mehr nutzbar. Idempotent.
*/
public function revoke(): void
{
if ($this->revoked_at !== null) {
return false;
if ($this->revoked_at === null) {
$this->revoked_at = now();
$this->save();
}
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return false;
}
return true;
}
}

View file

@ -8,32 +8,36 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Datei-Anhang einer Angebotsversion (Modul 6).
* Datei-Anhang einer Angebotsversion.
*
* 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.
* Bewusst API-kompatibel mit {@see BookingFile} gehalten (gleiche
* Method-Signaturen für `getURL()`, `getIconExt()`, `formatBytes()`,
* `getPath()`), damit Blade-Partials wie `booking/modal-new-booking-files`
* und die Dropzone-JS-Helfer direkt wiederverwendet werden können.
*
* @property int $id
* @property int $offer_version_id
* Speichert in disk `offer` (Konvention: `storage/app/offer/YYYY/MM/…`).
* Die Spalten-Schreibweise `mine` (statt `mime`) wurde aus BookingFile
* übernommen, damit Frontend-Code identisch funktioniert.
*
* @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
* @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 $offerVersion
*/
class OfferFile extends Model
{
use HasFactory;
protected $connection = 'mysql';
protected $table = 'offer_files';
protected $fillable = [
@ -54,7 +58,11 @@ class OfferFile extends Model
'include_in_pdf' => 'bool',
];
public static array $iconExt = [
/**
* Icon-Klassen nach Extension (kompatibel zu BookingFile::$icon_ext).
* @var array<string, string>
*/
public static $icon_ext = [
'default' => 'fa fa-file',
'pdf' => 'fa fa-file-pdf',
'jpg' => 'fa fa-file-image',
@ -64,36 +72,45 @@ class OfferFile extends Model
'docx' => 'fa fa-file-word',
];
public function version(): BelongsTo
public function offerVersion(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function getIconExt(): string
{
return self::$iconExt[$this->ext] ?? self::$iconExt['default'];
return self::$icon_ext[$this->ext] ?? self::$icon_ext['default'];
}
public function getURL(bool|string $do = false): string
/**
* Download/Preview-URL. Der zweite Parameter der Storage-Route (`offer`)
* wird vom `storage_file`-Controller für Disk-Auswahl ausgewertet
* muss serverseitig freigeschaltet sein (siehe Ticket B4 / StorageController).
*
* @param bool|string $download false = inline, 'download' = Attachment
*/
public function getURL($download = false): string
{
return route('storage_file', [$this->id, 'offer', $do]);
return route('storage_file', [$this->id, 'offer', $download]);
}
public function getPath(): string
{
// gleiches Idiom wie BookingFile::getPath() — Intelephense sieht
// path() im Filesystem-Contract in diesem Kontext nicht zuverlässig,
// php -l ist sauber; Methode ist Teil der Laravel-Filesystem-API seit 9.x.
/** @phpstan-ignore-next-line */
return \Storage::disk('offer')->path($this->dir . $this->filename);
}
public function formatBytes(int $precision = 2): string
{
$size = $this->size;
$size = (int) $this->size;
if ($size <= 0) {
return (string) $size;
return '0 bytes';
}
$base = log($size) / log(1024);
$base = log($size) / log(1024);
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
return round(pow(1024, $base - floor($base)), $precision) . $suffixes[(int) floor($base)];
}
}

View file

@ -8,29 +8,31 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Position einer Angebotsversion (Modul 6).
* Eine einzelne Positionszeile einer Angebotsversion (Leistung, Option,
* Rabatt, Versicherung, ). Positionen sind versionsgebunden eine neue
* Version hat ihre eigenen Zeilen.
*
* `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.
* `travel_program_id` und `fewo_lodging_id` bleiben aktuell OHNE
* Foreign-Key, solange die v2-Reiseverwaltung noch nicht nach Laravel
* migriert ist (siehe Risiko R4 im Entwicklungsplan). Ein Snapshot
* relevanter Quelldaten wird zusätzlich in `metadata` gespeichert,
* damit Angebote auch nach einem v2-Schema-Wechsel lesbar bleiben.
*
* @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
* @property int $id
* @property int $offer_version_id
* @property int $position
* @property string $type travel|service|option|discount|insurance|custom
* @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 $offerVersion
*/
class OfferItem extends Model
{
@ -43,6 +45,16 @@ class OfferItem extends Model
public const TYPE_INSURANCE = 'insurance';
public const TYPE_CUSTOM = 'custom';
public const TYPES = [
self::TYPE_TRAVEL,
self::TYPE_SERVICE,
self::TYPE_OPTION,
self::TYPE_DISCOUNT,
self::TYPE_INSURANCE,
self::TYPE_CUSTOM,
];
protected $connection = 'mysql';
protected $table = 'offer_items';
protected $fillable = [
@ -63,24 +75,25 @@ class OfferItem extends Model
'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',
'price_per_unit' => 'decimal:2',
'total_price' => 'decimal:2',
'metadata' => 'array',
];
public function version(): BelongsTo
public function offerVersion(): 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).
* Berechnet `total_price` aus `quantity * price_per_unit` wird vom
* OfferService vor dem Speichern aufgerufen, damit UI-Aggregate
* und DB-Werte konsistent bleiben.
*/
public function calculateTotal(): float
public function recomputeTotal(): void
{
return round($this->quantity * (float) $this->price_per_unit, 2);
$this->total_price = round($this->quantity * (float) $this->price_per_unit, 2);
}
}

View file

@ -11,33 +11,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Wiederverwendbare Angebots-Vorlage (Modul 6).
* Vorlage (Blueprint) für neue Angebote stellt Default-Texte und eine
* Default-Positionsliste (`default_items`, JSON) bereit. Beim Anlegen
* eines neuen Angebots über eine Vorlage werden diese Defaults in die
* erste OfferVersion kopiert.
*
* 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 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
* @property-read User $createdBy
*/
class OfferTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $connection = 'mysql';
protected $table = 'offer_templates';
protected $fillable = [
@ -55,23 +55,33 @@ class OfferTemplate extends Model
protected $casts = [
'branch_id' => 'int',
'default_items' => 'array',
'is_active' => 'bool',
'created_by' => 'int',
'is_active' => 'bool',
'default_items' => 'array',
];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
return $this->belongsTo(Branch::class, 'branch_id');
}
public function creator(): BelongsTo
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeActive(Builder $q): Builder
public function scopeActive(Builder $query): Builder
{
return $q->where('is_active', true);
return $query->where('is_active', true);
}
public function scopeForBranch(Builder $query, ?int $branchId): Builder
{
if ($branchId === null) {
return $query;
}
return $query->where(function (Builder $q) use ($branchId) {
$q->whereNull('branch_id')->orWhere('branch_id', $branchId);
});
}
}

View file

@ -11,38 +11,39 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Version eines Angebots (Modul 6).
* Eine konkrete Version (Snapshot) eines Angebots.
*
* 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).
* Alle inhaltlichen Felder (Texte, Preise, Gültigkeit, PDF-Pfad, Status,
* Anhänge) hängen hier nicht am Offer-Kopf. So bleiben bereits versendete
* Versionen unveränderlich (`isEditable() === false`), während eine neue
* Version ihre eigenen Texte/Positionen/Anhänge führt.
*
* @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
* @property int $id
* @property int $offer_id
* @property int $version_no
* @property string $status draft|sent|accepted|declined|expired|superseded
* @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 customer_link|admin|email
* @property array|null $template_document_ids JSON-Array von zentral hinterlegten Dokument-IDs
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Offer $offer
* @property-read Collection|OfferItem[] $items
* @property-read Collection|OfferFile[] $files
* @property-read Collection|OfferAccessToken[] $tokens
* @property-read OfferTemplate|null $template
* @property-read User $createdBy
*/
class OfferVersion extends Model
{
@ -57,8 +58,9 @@ class OfferVersion extends Model
public const ACCEPTED_VIA_LINK = 'customer_link';
public const ACCEPTED_VIA_ADMIN = 'admin';
public const ACCEPTED_VIA_MAIL = 'email';
public const ACCEPTED_VIA_EMAIL = 'email';
protected $connection = 'mysql';
protected $table = 'offer_versions';
protected $fillable = [
@ -84,19 +86,36 @@ class OfferVersion extends Model
protected $casts = [
'offer_id' => 'int',
'version_no' => 'int',
'valid_until' => 'date',
'total_price' => 'decimal:2',
'template_id' => 'int',
'pdf_archived' => 'bool',
'created_by' => 'int',
'total_price' => 'decimal:2',
'valid_until' => 'date',
'sent_at' => 'datetime',
'accepted_at' => 'datetime',
'pdf_archived' => 'bool',
'template_document_ids' => 'array',
'created_by' => 'int',
];
// ── Beziehungen ──────────────────────────────────────────────────────────
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
return $this->belongsTo(Offer::class, 'offer_id');
}
public function items(): HasMany
{
return $this->hasMany(OfferItem::class, 'offer_version_id')->orderBy('position');
}
public function files(): HasMany
{
return $this->hasMany(OfferFile::class, 'offer_version_id');
}
public function tokens(): HasMany
{
return $this->hasMany(OfferAccessToken::class, 'offer_version_id');
}
public function template(): BelongsTo
@ -104,23 +123,52 @@ class OfferVersion extends Model
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
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
/**
* Darf diese Version noch bearbeitet werden?
* Nur Draft-Versionen sind editierbar; alle anderen sind eingefroren.
*/
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* Liefert den zuletzt erzeugten, noch nicht widerrufenen Token
* (falls vorhanden) wird im Freigabe-Link verwendet.
*/
public function latestToken(): ?OfferAccessToken
{
return $this->tokens()
->whereNull('revoked_at')
->orderByDesc('id')
->first();
}
/**
* Formatierter Preis („1.234,56 "), konsistent mit der Darstellung
* im Buchungsdetail.
*/
public function totalPriceFormatted(): string
{
return number_format((float) $this->total_price, 2, ',', '.') . ' €';
}
/**
* Gibt die URL auf das zuletzt erzeugte PDF dieser Version zurück,
* oder null, wenn noch kein PDF existiert.
*/
public function getPdfUrl(bool $download = false): ?string
{
if (! $this->pdf_path) {
return null;
}
return route('offer_pdf', ['versionId' => $this->id, 'do' => $download ? 'download' : null]);
}
}

View file

@ -32,6 +32,10 @@ use Illuminate\Database\Eloquent\Model;
*/
class SidebarWidget extends Model
{
const HOMEPAGE_PLANNABLE_TRIPS = 'homepagePlannableTrips';
const HOMEPAGE_POPULAR_TRIPS = 'homepagePopularTrips';
const NEWS_SIDEBAR_WIDGET = 'newsSidebarWidget';
protected static $shows = [
'home' => 'Startseite',
@ -54,7 +58,9 @@ class SidebarWidget extends Model
'travelGuideSidebarWidget' => 'Reiseführer',
'travelMagazineSidebarWidget' => 'Reisemagazin',
'offersSidebarWidget' => 'Angebote',
'newsSidebarWidget' => 'News',
self::NEWS_SIDEBAR_WIDGET => 'News',
self::HOMEPAGE_PLANNABLE_TRIPS => 'Startseite: Aktuell planbare Reisen',
self::HOMEPAGE_POPULAR_TRIPS => 'Startseite: Beliebte Kulturreisen',
];
@ -105,23 +111,256 @@ class SidebarWidget extends Model
return rtrim($ret, ", ");
}
public function getComponentLabel()
{
if (!$this->component) {
return 'Nur HTML';
}
return isset(self::$components[$this->component]) ? self::$components[$this->component] : $this->component;
}
public function isHomepageTripsComponent()
{
return in_array($this->component, [self::HOMEPAGE_PLANNABLE_TRIPS, self::HOMEPAGE_POPULAR_TRIPS]);
}
public function isNewsComponent()
{
return $this->component === self::NEWS_SIDEBAR_WIDGET;
}
public function isStructuredConfigComponent()
{
return $this->isHomepageTripsComponent() || $this->isNewsComponent();
}
public function getConfig()
{
$config = json_decode($this->html, true);
if (!is_array($config)) {
$config = [];
}
$config['page_ids'] = isset($config['page_ids']) && is_array($config['page_ids'])
? array_values(array_filter($config['page_ids']))
: [];
$config['new_page_ids'] = isset($config['new_page_ids']) && is_array($config['new_page_ids'])
? array_values(array_filter($config['new_page_ids']))
: [];
$config['new_badge_active'] = !empty($config['new_badge_active']);
$config['news_limit'] = isset($config['news_limit']) ? max(1, min(12, (int) $config['news_limit'])) : 3;
return $config;
}
public function getHomepageConfig()
{
return $this->getConfig();
}
public function getHomepagePageIds()
{
return $this->getHomepageConfig()['page_ids'];
}
public function getHomepageNewPageIds()
{
return array_map('intval', $this->getHomepageConfig()['new_page_ids']);
}
public function getHomepageNewBadgeActive()
{
return $this->getHomepageConfig()['new_badge_active'];
}
public function getHomepageNewsLimit()
{
return $this->getConfig()['news_limit'];
}
public static function getHomepageTravelPageOptions($selectedIds = [])
{
$selectedIds = array_map('intval', (array) $selectedIds);
$items = self::getHomepageTravelPageItems($selectedIds);
$groupedItems = [];
foreach ($items as $item) {
$groupedItems[$item['country_group']][] = $item;
}
uksort($groupedItems, function ($a, $b) {
return strnatcasecmp(self::getHomepageSortValue($a), self::getHomepageSortValue($b));
});
$ret = '';
foreach ($groupedItems as $countryGroup => $groupItems) {
$ret .= '<optgroup label="'.e($countryGroup).'">'."\n";
foreach ($groupItems as $item) {
$attr = $item['selected'] ? 'selected="selected"' : '';
$label = e($item['label']);
$ret .= '<option value="'.$item['id'].'" data-label="'.$label.'" '.$attr.'>'.$label.'</option>'."\n";
}
$ret .= '</optgroup>'."\n";
}
return $ret;
}
public static function getHomepageTravelPageItems($selectedIds = [])
{
$selectedIds = array_map('intval', (array) $selectedIds);
$pages = Page::with([
'travel_program_content.travel_program_countries.travel_country',
'travel_program_content.travel_country_content',
])
->where('status', 1)
->whereNotNull('travel_program')
->where('travel_program', '>', 0)
->whereHas('travel_program_content', function ($query) {
$query->where('status', 1);
})
->orderBy('title')
->get()
->unique('travel_program');
$items = [];
foreach ($pages as $page) {
$countryGroup = self::getHomepageTravelPageCountryLabel($page);
$items[] = [
'id' => (int) $page->id,
'label' => self::getHomepageTravelPageLabel($page),
'country_group' => $countryGroup,
'sort_title' => $page->title,
'selected' => in_array((int) $page->id, $selectedIds),
];
}
usort($items, function ($a, $b) {
$countryCompare = strnatcasecmp(
self::getHomepageSortValue($a['country_group']),
self::getHomepageSortValue($b['country_group'])
);
if ($countryCompare !== 0) {
return $countryCompare;
}
return strnatcasecmp(self::getHomepageSortValue($a['sort_title']), self::getHomepageSortValue($b['sort_title']));
});
return $items;
}
public function getHomepageSelectedTravelPageItems()
{
$selectedIds = array_map('intval', $this->getHomepagePageIds());
if (empty($selectedIds)) {
return [];
}
$newPageIds = $this->getHomepageNewPageIds();
$pages = Page::with([
'travel_program_content.travel_program_countries.travel_country',
'travel_program_content.travel_country_content',
])
->whereIn('id', $selectedIds)
->where('status', 1)
->whereHas('travel_program_content', function ($query) {
$query->where('status', 1);
})
->get()
->keyBy('id');
$items = [];
$usedTravelPrograms = [];
foreach ($selectedIds as $pageId) {
if (!$pages->has($pageId)) {
continue;
}
$page = $pages->get($pageId);
$travelProgramId = (int) $page->travel_program;
if (in_array($travelProgramId, $usedTravelPrograms)) {
continue;
}
$usedTravelPrograms[] = $travelProgramId;
$items[] = [
'id' => (int) $page->id,
'label' => self::getHomepageTravelPageLabel($page),
'is_new' => in_array((int) $page->id, $newPageIds),
];
}
return $items;
}
protected static function getHomepageTravelPageLabel(Page $page)
{
$country = self::getHomepageTravelPageCountryLabel($page);
$travelProgram = $page->travel_program_content;
$programId = $travelProgram ? $travelProgram->id : $page->travel_program;
return $country.' | Reise, '.$page->title.' (#'.$programId.')';
}
protected static function getHomepageTravelPageCountryLabel(Page $page)
{
$countryNames = self::getHomepageTravelPageCountryNames($page);
return !empty($countryNames) ? implode(', ', $countryNames) : 'Ohne Reiseland';
}
protected static function getHomepageTravelPageCountryNames(Page $page)
{
$travelProgram = $page->travel_program_content;
$countryNames = [];
if ($travelProgram && $travelProgram->travel_program_countries) {
foreach ($travelProgram->travel_program_countries as $programCountry) {
if ($programCountry->travel_country && $programCountry->travel_country->name) {
$countryNames[] = $programCountry->travel_country->name;
}
}
}
if (empty($countryNames) && $travelProgram && $travelProgram->travel_country_content) {
$countryNames[] = $travelProgram->travel_country_content->name;
}
$countryNames = array_unique($countryNames);
usort($countryNames, function ($a, $b) {
return strnatcasecmp(self::getHomepageSortValue($a), self::getHomepageSortValue($b));
});
return $countryNames;
}
protected static function getHomepageSortValue($value)
{
return str_replace(
['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'],
['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'],
(string) $value
);
}
/*
* public function getVotesDetailAttribute($details)
{
return json_decode($details, true);
}
then when you will call $store->votes_detail you will get the expected result.
After that you can use mutators to convert an array back to JSON when it is saved back in the DB. Define the method setVotesDetailAttribute($value) as follows:
public function setVotesDetailsAttribute($value)
{
$this->attributes['votes_detail'] = json_encode($value);
}
*/
{
* return json_decode($details, true);
}
* then when you will call $store->votes_detail you will get the expected result.
*
* After that you can use mutators to convert an array back to JSON when it is saved back in the DB. Define the method setVotesDetailAttribute($value) as follows:
*
* public function setVotesDetailsAttribute($value)
* {
* $this->attributes['votes_detail'] = json_encode($value);
* }
*/
}

View file

@ -295,6 +295,16 @@ class TravelProgram extends Model
return $this->hasOne(TravelProgramCountry::class, 'program_id');
}
public function travel_program_countries()
{
return $this->hasMany(TravelProgramCountry::class, 'program_id');
}
public function travel_country_content()
{
return $this->belongsTo(TravelCountry::class, 'travel_country');
}
public function travel_program_destination()
{
//return $this->hasOne(TravelProgramDestination::class, 'program_id');

View file

@ -569,6 +569,9 @@ class TravelUserBookingFewo extends Model
public function getInvoicePathFile(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getInvoiceFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
return Storage::disk('fewo_invoices')->path($dir.$filename);
}
@ -578,6 +581,9 @@ class TravelUserBookingFewo extends Model
public function getInvoiceUrlFile(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getInvoiceFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
return Storage::disk('fewo_invoices')->url($dir.$filename);
}
@ -587,6 +593,9 @@ class TravelUserBookingFewo extends Model
public function getInvoiceLastModified(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getInvoiceFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->format("H:i d.m.Y");
@ -598,6 +607,9 @@ class TravelUserBookingFewo extends Model
public function isChangeLowerInvoiceCreate(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getInvoiceFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->gt($this->last_change_at);
}
@ -645,6 +657,9 @@ class TravelUserBookingFewo extends Model
public function getTravelInfoPathFile(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getTravelInfoFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
return Storage::disk('fewo_infos')->path($dir.$filename);
}
@ -654,6 +669,9 @@ class TravelUserBookingFewo extends Model
public function getTravelInfoUrlFile(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getTravelInfoFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
return Storage::disk('fewo_infos')->url($dir.$filename);
}
@ -663,6 +681,9 @@ class TravelUserBookingFewo extends Model
public function getTravelInfoLastModified(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getTravelInfoFileName();
if(!$filename){
return false;
}
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
return Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->format("H:i d.m.Y");
}
@ -672,7 +693,10 @@ class TravelUserBookingFewo extends Model
public function isChangeLowerTravelInfoCreate(){
$dir = $this->getBookingDateYear()."/";
$filename = $this->getTravelInfoFileName();
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
if(!$filename){
return false;
}
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
return Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->gt($this->last_change_at);
}
return false;

View file

@ -0,0 +1,100 @@
<?php
namespace App\Repositories;
use App\Models\OfferFile;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Response;
/**
* Datei-Repository für Angebots-Anhänge.
*
* Bewusst API-kompatibel mit {@see BookingFileRepository} gehalten:
* identische JSON-Response-Struktur, so dass die Dropzone-/Upload-
* Frontends aus dem Booking-Modul ohne Anpassung geteilt werden können.
*
* Unterschiede:
* - Persistiert in `offer_files` mit `offer_version_id` statt `booking_id`.
* - Zusätzliches Feld `include_in_pdf` (bool, default true) steuert, ob
* der Anhang in der PDF-Generierung mitgeführt wird (Ticket B5).
*/
class OfferFileRepository extends FileRepository
{
/** @var OfferFile|null Nach save() gesetzt. */
protected $offer_file;
protected $offer_version_id;
protected $identifier;
protected bool $include_in_pdf = true;
public function __construct(OfferFile $model)
{
parent::__construct();
$this->model = $model;
/**
* Offer-Upload erlaubt zusätzlich gängige Dokument-Formate
* (Reise-Exposés als doc/docx, Kalkulationen als xlsx).
*/
$this->rules = [
'file' => 'required|mimes:pdf,jpeg,jpg,png,doc,docx,xls,xlsx|max:32768',
];
$this->messages = [
'file.mimes' => 'Datei ist kein PDF/JPG/PNG/DOC/XLS Format',
'file.required' => 'Datei wird benötigt',
];
}
public function save(): void
{
$this->offer_file = OfferFile::create([
'offer_version_id' => $this->offer_version_id,
'identifier' => $this->identifier,
'filename' => $this->allowed_filename,
'dir' => $this->dir,
'original_name' => $this->originalName,
'ext' => $this->extension,
'mine' => $this->mine,
'size' => $this->size,
'include_in_pdf' => $this->include_in_pdf,
]);
}
public function response(): JsonResponse
{
return Response::json([
'error' => false,
'filename' => $this->allowed_filename,
'file_id' => $this->offer_file->id,
'file_data' => $this->extension,
'file_icon' => $this->offer_file->getIconExt(),
'file_format_bytes' => $this->offer_file->formatBytes(),
'file_url' => $this->offer_file->getURL(),
'include_in_pdf' => $this->offer_file->include_in_pdf,
'redirect' => '',
'code' => 200,
], 200);
}
/**
* Aktualisiert das `include_in_pdf`-Flag (für den Toggle in der UI).
* Nur erlaubt, wenn die Version noch editierbar ist (Draft).
*/
public function toggleIncludeInPdf(OfferFile $file, bool $include): OfferFile
{
$version = $file->offerVersion;
if ($version && ! $version->isEditable()) {
throw new \DomainException(
"OfferFile #{$file->id} gehört zu einer eingefrorenen Version "
. "(status={$version->status}) und kann nicht mehr geändert werden."
);
}
$file->include_in_pdf = $include;
$file->save();
return $file;
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Repositories;
use App\Models\Offer;
use App\Models\OfferVersion;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
/**
* Repository für das Angebot (Kopf-Datensatz).
*
* Konventionen:
* - Nur DB-Operationen, keine Business-Rules (die liegen im OfferService / A4).
* - Alle Multi-Statement-Operationen laufen in einer Transaktion.
* - `offer_number` ist UNIQUE auf DB-Ebene wir akzeptieren Race-Kollisionen
* und versuchen bis zu 3×, eine freie Nummer zu ziehen.
*/
class OfferRepository extends BaseRepository
{
/** Max. Retries beim Generieren einer freien Angebotsnummer. */
public const MAX_NUMBER_RETRIES = 3;
public function __construct(Offer $model)
{
$this->model = $model;
}
/**
* Legt ein neues Angebot + initiale Version 1 (status=draft) an.
* Rollback erfolgt, falls irgendein Teilschritt fehlschlägt.
*
* Erwartete Keys in `$data`:
* - `status` (optional, default 'draft')
* - `current_version`: array mit Daten für die erste OfferVersion
* (mindestens `created_by`, optional `headline`, `valid_until`, `template_id`)
*
* @param array<string,mixed> $data
*/
public function create(array $data, int $contactId, ?int $inquiryId = null, ?int $bookingId = null): Offer
{
return DB::transaction(function () use ($data, $contactId, $inquiryId, $bookingId) {
$versionData = $data['current_version'] ?? [];
$createdBy = $versionData['created_by'] ?? $data['created_by'] ?? null;
if (! $createdBy) {
throw new \InvalidArgumentException('OfferRepository::create benötigt created_by');
}
$offer = new Offer([
'offer_number' => $this->generateOfferNumber(),
'contact_id' => $contactId,
'inquiry_id' => $inquiryId,
'booking_id' => $bookingId,
'status' => $data['status'] ?? Offer::STATUS_DRAFT,
'created_by' => $createdBy,
]);
$offer->save();
$version = new OfferVersion(array_merge([
'status' => OfferVersion::STATUS_DRAFT,
'total_price' => 0,
'created_by' => $createdBy,
], $versionData, [
'offer_id' => $offer->id,
'version_no' => 1,
]));
$version->save();
$offer->current_version_id = $version->id;
$offer->save();
return $offer->fresh(['currentVersion']);
});
}
/**
* Erzeugt die nächste freie Angebotsnummer im Format `YYYY-NNNNN`.
* Race-Safety: `lockForUpdate()` serialisiert konkurrierende Generierungen
* pro Jahr. Zusätzlicher Schutz über den UNIQUE-Index auf `offer_number`.
*/
public function generateOfferNumber(): string
{
$year = Carbon::now()->format('Y');
$attempt = 0;
while (true) {
$attempt++;
try {
return DB::transaction(function () use ($year) {
$last = Offer::withTrashed()
->where('offer_number', 'LIKE', $year . '-%')
->orderByDesc('offer_number')
->lockForUpdate()
->value('offer_number');
$nextSeq = $last ? ((int) substr($last, 5)) + 1 : 1;
return sprintf('%s-%05d', $year, $nextSeq);
});
} catch (QueryException $e) {
if ($attempt >= self::MAX_NUMBER_RETRIES) {
throw $e;
}
usleep(50_000 * $attempt);
}
}
}
public function findByNumber(string $offerNumber): ?Offer
{
return Offer::where('offer_number', $offerNumber)->first();
}
/** Convenience für Controller: Offer inkl. Version/Items/Files eager-loaden. */
public function getByIdFull(int $id): Offer
{
return Offer::with([
'contact',
'inquiry',
'booking',
'currentVersion.items',
'currentVersion.files',
'currentVersion.tokens',
'versions' => fn ($q) => $q->orderBy('version_no'),
'createdBy',
])->findOrFail($id);
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Repositories;
use App\Models\OfferItem;
use App\Models\OfferTemplate;
use App\Models\OfferVersion;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Repository für Angebots-Vorlagen.
*
* Vorlagen bestehen aus Default-Texten (Headline/Intro/Itinerary/Closing)
* und einer Default-Positionsliste (JSON in `default_items`). Über
* {@see applyTo()} wird eine Vorlage in eine existierende Draft-Version
* hineinkopiert (Texte + Positionen).
*/
class OfferTemplateRepository extends BaseRepository
{
public function __construct(OfferTemplate $model)
{
$this->model = $model;
}
/**
* @param array<string,mixed> $data
*/
public function create(array $data): OfferTemplate
{
return OfferTemplate::create($data);
}
/**
* @param array<string,mixed> $data
*/
public function update(OfferTemplate $template, array $data): OfferTemplate
{
$template->fill($data);
$template->save();
return $template->fresh();
}
/**
* Aktive Vorlagen, optional gefiltert nach Branch (inkl. global = branch_id NULL).
*
* @return Collection<int, OfferTemplate>
*/
public function listActive(?int $branchId = null): Collection
{
return OfferTemplate::query()
->active()
->forBranch($branchId)
->orderBy('name')
->get();
}
/**
* Wendet eine Vorlage auf eine Draft-Version an:
* - kopiert Default-Texte (nur in leere Felder, bestehender Text wird nicht überschrieben)
* - legt Positionen aus `default_items` an (ersetzt bestehende Items komplett)
* - merkt sich die Vorlage in `offer_version.template_id`
*
* @return OfferVersion fresh, inkl. items
*/
public function applyTo(OfferTemplate $template, OfferVersion $version): OfferVersion
{
if (! $version->isEditable()) {
throw new \DomainException(
"OfferVersion #{$version->id} ist eingefroren (status={$version->status}); "
. 'Vorlage kann nicht mehr angewendet werden.'
);
}
return DB::transaction(function () use ($template, $version) {
$version->template_id = $template->id;
$version->headline = $version->headline ?: $template->default_headline;
$version->intro_text = $version->intro_text ?: $template->default_intro;
$version->itinerary_text = $version->itinerary_text ?: $template->default_itinerary;
$version->closing_text = $version->closing_text ?: $template->default_closing;
$version->save();
OfferItem::where('offer_version_id', $version->id)->delete();
$position = 0;
$sum = 0.0;
foreach ((array) $template->default_items as $itemData) {
$item = new OfferItem(array_merge($itemData, [
'offer_version_id' => $version->id,
'position' => $position++,
]));
$item->recomputeTotal();
$item->save();
$sum += (float) $item->total_price;
}
$version->total_price = $sum;
$version->save();
return $version->fresh(['items']);
});
}
}

View file

@ -0,0 +1,207 @@
<?php
namespace App\Repositories;
use App\Models\Offer;
use App\Models\OfferItem;
use App\Models\OfferVersion;
use Illuminate\Support\Facades\DB;
/**
* Repository für Angebots-Versionen.
*
* Zentrale Invarianten:
* - Nur Draft-Versionen sind editierbar (sonst `DomainException`).
* - `createNewVersion()` dupliziert alle Items + Files, markiert die alte
* Version als `superseded` und schiebt `offer.current_version_id` um.
* - `markSent()` friert die Version ein (status=sent, sent_at=now) und hebt
* den Offer-Status von `draft` auf `sent`.
*/
class OfferVersionRepository extends BaseRepository
{
public function __construct(OfferVersion $model)
{
$this->model = $model;
}
/**
* Aktualisiert eine Draft-Version und synchronisiert ggf. die Items.
* Total-Preis wird anschließend aus den Items neu aufsummiert.
*
* @param array<string,mixed> $data beliebige OfferVersion-Felder + optional 'items' => [...]
*/
public function updateDraft(OfferVersion $v, array $data): OfferVersion
{
if (! $v->isEditable()) {
throw new \DomainException(
"OfferVersion #{$v->id} ist nicht mehr editierbar (status={$v->status}). "
. 'Für Änderungen an versendeten Versionen createNewVersion() nutzen.'
);
}
return DB::transaction(function () use ($v, $data) {
$items = $data['items'] ?? null;
unset($data['items']);
$v->fill($data);
$v->save();
if (is_array($items)) {
$this->syncItems($v, $items);
$v->total_price = (float) OfferItem::where('offer_version_id', $v->id)->sum('total_price');
$v->save();
}
return $v->fresh(['items']);
});
}
/**
* Dupliziert die aktuelle Version eines Offers in eine neue Draft-Version.
*
* - `version_no` = MAX(version_no) + 1
* - Items + Files werden als neue Zeilen kopiert (Datei-Binaries bleiben
* physisch geteilt die Datei-Zeile bekommt nur eine neue DB-ID).
* - Alte Version `superseded` (eingefroren).
* - `offer.current_version_id` wird auf die neue Version gesetzt.
*
* Alles atomar in einer Transaktion.
*/
public function createNewVersion(Offer $offer): OfferVersion
{
return DB::transaction(function () use ($offer) {
/** @var OfferVersion|null $current */
$current = $offer->currentVersion()->with(['items', 'files'])->first();
if (! $current) {
throw new \RuntimeException("Offer #{$offer->id} hat keine currentVersion.");
}
$nextVersionNo = ((int) $offer->versions()->max('version_no')) + 1;
$new = $current->replicate([
'version_no',
'status',
'sent_at',
'accepted_at',
'accepted_via',
'pdf_path',
'pdf_archived',
'created_at',
'updated_at',
]);
$new->version_no = $nextVersionNo;
$new->status = OfferVersion::STATUS_DRAFT;
$new->sent_at = null;
$new->accepted_at = null;
$new->accepted_via = null;
$new->pdf_path = null;
$new->pdf_archived = false;
$new->save();
foreach ($current->items as $item) {
$newItem = $item->replicate(['offer_version_id', 'created_at', 'updated_at']);
$newItem->offer_version_id = $new->id;
$newItem->save();
}
foreach ($current->files as $file) {
$newFile = $file->replicate(['offer_version_id', 'created_at', 'updated_at']);
$newFile->offer_version_id = $new->id;
$newFile->save();
}
$current->status = OfferVersion::STATUS_SUPERSEDED;
$current->save();
$offer->current_version_id = $new->id;
$offer->status = Offer::STATUS_DRAFT;
$offer->save();
return $new->fresh(['items', 'files']);
});
}
/**
* Markiert eine Version als versendet und hebt das Offer (falls noch draft)
* auf Status `sent`. Idempotent: erneutes Aufrufen ändert nichts.
*/
public function markSent(OfferVersion $v): OfferVersion
{
if ($v->status === OfferVersion::STATUS_SENT) {
return $v;
}
return DB::transaction(function () use ($v) {
$v->status = OfferVersion::STATUS_SENT;
$v->sent_at = $v->sent_at ?? now();
$v->save();
$offer = $v->offer;
if ($offer && $offer->status === Offer::STATUS_DRAFT) {
$offer->status = Offer::STATUS_SENT;
$offer->save();
}
return $v->fresh();
});
}
/**
* Markiert eine Version als akzeptiert (durch Admin oder Kunde-via-Token).
* Setzt gleichzeitig den Offer-Status auf `accepted`.
*/
public function markAccepted(OfferVersion $v, string $via = OfferVersion::ACCEPTED_VIA_ADMIN): OfferVersion
{
return DB::transaction(function () use ($v, $via) {
$v->status = OfferVersion::STATUS_ACCEPTED;
$v->accepted_at = $v->accepted_at ?? now();
$v->accepted_via = $via;
$v->save();
$offer = $v->offer;
if ($offer && $offer->status !== Offer::STATUS_ACCEPTED) {
$offer->status = Offer::STATUS_ACCEPTED;
$offer->save();
}
return $v->fresh();
});
}
/**
* Synchronisiert die Items einer Version mit einer Item-Liste:
* vorhandene `id`s werden aktualisiert, neue angelegt, fehlende gelöscht.
* Position wird aus Array-Index übernommen (0-basiert).
*
* @param array<int, array<string,mixed>> $items
*/
protected function syncItems(OfferVersion $v, array $items): void
{
$keepIds = [];
foreach (array_values($items) as $position => $itemData) {
$itemData['offer_version_id'] = $v->id;
$itemData['position'] = $position;
if (! empty($itemData['id'])) {
/** @var OfferItem $model */
$model = OfferItem::where('offer_version_id', $v->id)
->where('id', $itemData['id'])
->firstOrFail();
unset($itemData['id']);
$model->fill($itemData);
} else {
unset($itemData['id']);
$model = new OfferItem($itemData);
}
$model->recomputeTotal();
$model->save();
$keepIds[] = $model->id;
}
OfferItem::where('offer_version_id', $v->id)
->when(! empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
->delete();
}
}

View file

@ -58,6 +58,10 @@ class TravelUserBookingFewoRepository extends BaseRepository
{
$model = TravelUserBookingFewo::findOrFail($id);
if (!$model->invoice_number) {
return false;
}
$travel_info_user_text = str_replace("", "&euro;", $travel_info_user_text);
$model->info_mail_text = $travel_info_user_text;
$model->save();
@ -70,6 +74,9 @@ class TravelUserBookingFewoRepository extends BaseRepository
$path = $model->getTravelInfoPath();
$dir = $model->getTravelInfoDir();
$filename = $model->getTravelInfoFileName();
if (!$filename) {
return false;
}
$pdf_file = new CreatePDF('pdf.travel_info_fewo', 'fewo_infos');
$pdf_file->create($data, $filename, 'save', $path);
$pdf_file->merger($dir, $filename, 'sterntours-template-logo');

View file

@ -0,0 +1,385 @@
<?php
namespace App\Services;
use App\Events\Offer\OfferAccepted;
use App\Events\Offer\OfferDeclined;
use App\Events\Offer\OfferSent;
use App\Events\Offer\OfferWithdrawn;
use App\Exceptions\Offer\InvalidOfferStatusException;
use App\Exceptions\Offer\OfferNotEditableException;
use App\Models\Booking;
use App\Models\Lead;
use App\Models\Offer;
use App\Models\OfferAccessToken;
use App\Models\OfferTemplate;
use App\Models\OfferVersion;
use App\Repositories\OfferFileRepository;
use App\Repositories\OfferRepository;
use App\Repositories\OfferTemplateRepository;
use App\Repositories\OfferVersionRepository;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* Business-Logik-Schicht für das Offers-Modul.
*
* Verantwortung:
* - Orchestriert die Repositories (A3) inkl. Statusübergänge
* - Setzt Guards durch (Status, Editierbarkeit, Idempotenz)
* - Dispatcht Domain-Events (`OfferSent`, `OfferAccepted`, ). Die
* eigentliche Mail/Audit-Log-Arbeit erledigen Listener (B6).
* - Regelt atomare Multi-Schritt-Transitionen (send, accept, supersede,
* convertToBooking).
*
* Bewusst NICHT hier:
* - PDF-Rendering (B5 eigener PdfService).
* - Mail-Composition (B6 OfferMail-Listener).
* - HTML-Sanitizing / Eingabe-Validierung (A5 FormRequests).
*/
class OfferService
{
public function __construct(
protected OfferRepository $offers,
protected OfferVersionRepository $versions,
protected OfferTemplateRepository $templates,
protected OfferFileRepository $files,
) {
}
// ─────────────────────────────────────────────────────────────────────────
// Anlage
// ─────────────────────────────────────────────────────────────────────────
/**
* Legt ein leeres Angebot für einen bestehenden Kontakt an, optional mit
* einer Vorlage (Template-Texte + Default-Positionen).
*/
public function createBlank(int $contactId, ?int $templateId = null, ?int $createdBy = null): Offer
{
return DB::transaction(function () use ($contactId, $templateId, $createdBy) {
$offer = $this->offers->create(
data: ['current_version' => ['created_by' => $this->resolveUserId($createdBy)]],
contactId: $contactId,
);
if ($templateId !== null) {
$template = OfferTemplate::findOrFail($templateId);
$this->templates->applyTo($template, $offer->currentVersion);
$offer->refresh();
}
return $offer->load(['currentVersion.items', 'contact']);
});
}
/**
* Legt ein Angebot aus einer Anfrage (Inquiry / Lead) an: Kontakt wird
* übernommen, `offers.inquiry_id` verknüpft.
*/
public function createFromInquiry(int $inquiryId, ?int $templateId = null, ?int $createdBy = null): Offer
{
return DB::transaction(function () use ($inquiryId, $templateId, $createdBy) {
/** @var Lead $inquiry */
$inquiry = Lead::findOrFail($inquiryId);
$offer = $this->offers->create(
data: ['current_version' => ['created_by' => $this->resolveUserId($createdBy)]],
contactId: (int) $inquiry->customer_id,
inquiryId: $inquiryId,
);
if ($templateId !== null) {
$template = OfferTemplate::findOrFail($templateId);
$this->templates->applyTo($template, $offer->currentVersion);
$offer->refresh();
}
return $offer->load(['currentVersion.items', 'contact', 'inquiry']);
});
}
/**
* Wendet eine Vorlage auf eine (Draft-)Version an.
* Thin Wrapper um den Template-Repo wirft {@see OfferNotEditableException}
* statt der rohen DomainException, damit Caller sauber unterscheiden können.
*/
public function applyTemplate(OfferVersion $v, OfferTemplate $template): OfferVersion
{
if (! $v->isEditable()) {
throw new OfferNotEditableException($v, 'Vorlage anwenden');
}
return $this->templates->applyTo($template, $v);
}
// ─────────────────────────────────────────────────────────────────────────
// Bearbeitung
// ─────────────────────────────────────────────────────────────────────────
/**
* Aktualisiert Texte/Items einer Draft-Version. Bei `sent`/`superseded`
* wird `OfferNotEditableException` geworfen Caller sollte vorher
* `supersede()` nutzen, um eine neue Draft-Version zu erzeugen.
*
* @param array<string,mixed> $data
*/
public function updateVersion(OfferVersion $v, array $data): OfferVersion
{
if (! $v->isEditable()) {
throw new OfferNotEditableException($v, 'aktualisieren');
}
return $this->versions->updateDraft($v, $data);
}
/**
* Erzeugt eine neue Draft-Version (Kopie der aktuellen), setzt die alte
* auf `superseded` und invalidiert alle noch aktiven Access-Tokens der
* Vorgänger-Version.
*/
public function supersede(OfferVersion $v): OfferVersion
{
$offer = $v->offer;
return DB::transaction(function () use ($offer, $v) {
foreach ($v->tokens()->whereNull('revoked_at')->get() as $token) {
$token->revoke();
}
return $this->versions->createNewVersion($offer);
});
}
// ─────────────────────────────────────────────────────────────────────────
// Versand
// ─────────────────────────────────────────────────────────────────────────
/**
* Versendet eine Draft-Version: erzeugt frischen Access-Token, setzt
* status=sent + sent_at, hebt den Offer-Status (falls draft) auf sent
* und dispatcht `OfferSent`.
*
* Die Mail selbst wird vom OfferSentListener (Ticket B6) versendet
* hier nur der Token + State-Wechsel, damit der State-Teil testbar bleibt.
*
* Guard: Version muss editierbar (= draft) sein UND die aktuelle des Offers.
*
* @param array<string,mixed> $mailData Betreff/Body/CC aus Versand-Modal
*/
public function send(OfferVersion $v, array $mailData): void
{
if (! $v->isEditable()) {
throw new OfferNotEditableException($v, 'versenden');
}
if ($v->offer && $v->offer->current_version_id !== $v->id) {
throw new OfferNotEditableException($v, 'versenden (nicht aktuelle Version)');
}
DB::transaction(function () use ($v, $mailData) {
$expiresAt = $this->extractExpires($mailData);
$token = OfferAccessToken::generate($v, $expiresAt);
$v = $this->versions->markSent($v);
OfferSent::dispatch($v->offer, $v, $token, $mailData);
});
}
// ─────────────────────────────────────────────────────────────────────────
// Admin-Statuswechsel
// ─────────────────────────────────────────────────────────────────────────
/**
* Markiert eine versendete Version als `accepted`. Gilt für Admin-Klick
* ebenso wie für den Customer-Link (via=`customer_link`).
*
* Regel: Nur auf `sent` möglich. Bei Erfolg wechselt auch der Offer
* auf `accepted`; parallel noch aktive ältere Versionen werden NICHT
* angefasst (sind ohnehin schon `superseded` durch createNewVersion,
* oder gehören in einer sauberen History).
*/
public function markAccepted(
OfferVersion $v,
string $via = OfferVersion::ACCEPTED_VIA_ADMIN,
?string $ip = null,
?string $ua = null,
): void {
if ($v->status !== OfferVersion::STATUS_SENT) {
throw new \DomainException(
"OfferVersion #{$v->id} ist nicht sent (status={$v->status}); "
. 'accept nur auf versendeten Versionen möglich.'
);
}
DB::transaction(function () use ($v, $via, $ip, $ua) {
$v = $this->versions->markAccepted($v, $via);
OfferAccepted::dispatch($v->offer, $v, $via, $ip, $ua);
});
}
public function markDeclined(OfferVersion $v, string $via, ?string $reason = null): void
{
if (! in_array($v->status, [OfferVersion::STATUS_SENT, OfferVersion::STATUS_ACCEPTED], true)) {
throw new \DomainException(
"OfferVersion #{$v->id} kann in status={$v->status} nicht abgelehnt werden."
);
}
DB::transaction(function () use ($v, $via, $reason) {
$v->status = OfferVersion::STATUS_DECLINED;
$v->save();
$offer = $v->offer;
if ($offer && in_array($offer->status, [Offer::STATUS_SENT, Offer::STATUS_ACCEPTED], true)) {
$offer->status = Offer::STATUS_DECLINED;
$offer->save();
}
OfferDeclined::dispatch($offer, $v->fresh(), $via, $reason);
});
}
/**
* Zieht den Offer vollständig zurück: status=withdrawn, alle aktiven
* Tokens werden widerrufen. Danach kann weder Kunde noch Admin
* zusagen; Historie (Versionen) bleibt erhalten.
*/
public function withdraw(Offer $offer, string $reason): void
{
if (in_array($offer->status, [Offer::STATUS_ACCEPTED, Offer::STATUS_WITHDRAWN], true)) {
throw new InvalidOfferStatusException(
$offer,
[Offer::STATUS_DRAFT, Offer::STATUS_SENT, Offer::STATUS_DECLINED, Offer::STATUS_EXPIRED],
'withdraw',
);
}
DB::transaction(function () use ($offer, $reason) {
$offer->status = Offer::STATUS_WITHDRAWN;
$offer->save();
OfferAccessToken::query()
->where('offer_id', $offer->id)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
OfferWithdrawn::dispatch($offer->fresh(), $reason);
});
}
// ─────────────────────────────────────────────────────────────────────────
// Wartung
// ─────────────────────────────────────────────────────────────────────────
/**
* Scheduler-Job (Ticket A7): setzt versendete Versionen mit abgelaufenem
* `valid_until` auf `expired`, ebenso den Offer aber nur, wenn es noch
* keine neuere akzeptierte Version gibt.
*
* @return int Anzahl der expirierten Versionen.
*/
public function purgeExpired(): int
{
$today = Carbon::today();
$expired = OfferVersion::query()
->where('status', OfferVersion::STATUS_SENT)
->whereNotNull('valid_until')
->whereDate('valid_until', '<', $today)
->get();
$count = 0;
foreach ($expired as $v) {
DB::transaction(function () use ($v, &$count) {
$v->status = OfferVersion::STATUS_EXPIRED;
$v->save();
$offer = $v->offer;
if ($offer && $offer->status === Offer::STATUS_SENT
&& $offer->current_version_id === $v->id
) {
$offer->status = Offer::STATUS_EXPIRED;
$offer->save();
}
$count++;
});
}
return $count;
}
// ─────────────────────────────────────────────────────────────────────────
// Konversion Offer → Buchung
// ─────────────────────────────────────────────────────────────────────────
/**
* Wandelt ein akzeptiertes Offer in eine Buchung um.
*
* Aktueller Zustand (Ticket B8 finalisiert das):
* - Guards: Offer.status=accepted UND Offer.booking_id IS NULL.
* - Der eigentliche Booking-Create-Aufruf hängt an `BookingService::createManual()`
* aus Modul 4. Solange der nicht existiert, wirft diese Methode eine
* `\RuntimeException` mit Hinweis.
*
* Idempotenz: Zweiter Aufruf schlägt mit {@see InvalidOfferStatusException} fehl
* (booking_id ist dann schon gesetzt).
*
* @see docs/offers/umsetzung.md Ticket B8
*/
public function convertToBooking(Offer $offer): Booking
{
if ($offer->status !== Offer::STATUS_ACCEPTED) {
throw new InvalidOfferStatusException($offer, [Offer::STATUS_ACCEPTED], 'convertToBooking');
}
if ($offer->booking_id !== null) {
throw new \DomainException(sprintf(
'Offer #%d wurde bereits in Booking #%d überführt (idempotent).',
$offer->id,
$offer->booking_id
));
}
throw new \RuntimeException(
'OfferService::convertToBooking() wartet auf BookingService::createManual() '
. '(Modul 4). Wird in Ticket B8 fertiggestellt.'
);
}
// ─────────────────────────────────────────────────────────────────────────
// intern
// ─────────────────────────────────────────────────────────────────────────
/**
* Ermittelt den Ersteller: explizit übergeben > aktuell eingeloggter User.
* Wirft, falls beides null Service darf nie user-less angelegt werden.
*/
protected function resolveUserId(?int $explicit): int
{
if ($explicit !== null) {
return $explicit;
}
$userId = Auth::id();
if ($userId === null) {
throw new \RuntimeException(
'OfferService benötigt entweder einen expliziten $createdBy '
. 'oder einen eingeloggten User (Auth::id()).'
);
}
return (int) $userId;
}
/**
* Holt `expires_at` aus dem Mail-Data-Array (Form-Feld), akzeptiert
* Carbon oder parseable String; null kein Ablauf.
*/
protected function extractExpires(array $mailData): ?Carbon
{
$raw = $mailData['expires_at'] ?? null;
if ($raw === null || $raw === '') {
return null;
}
if ($raw instanceof Carbon) {
return $raw;
}
return Carbon::parse($raw);
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;
class ReportDateFilterService
{
public static function parseDate($value, $context = 'report date filter')
{
$value = trim((string)$value);
if ($value === '') {
return null;
}
$normalized = preg_replace('/\s+/', '', $value);
// Falls das Jahr nur 3-stellig ist (z. B. 28.02.202), letzte Ziffer vom aktuellen Jahr ergänzen.
if (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{3})$/', $normalized, $matches)) {
$normalized = sprintf(
'%02d.%02d.%s%d',
(int)$matches[1],
(int)$matches[2],
$matches[3],
((int)date('Y')) % 10
);
}
try {
if (preg_match('/^\d{1,2}\.\d{1,2}\.\d{4}$/', $normalized)) {
return Carbon::createFromFormat('d.m.Y', $normalized)->format('Y-m-d');
}
return Carbon::parse($normalized)->format('Y-m-d');
} catch (Throwable $e) {
Log::warning('Invalid report date filter ignored', [
'context' => $context,
'value' => $value,
'normalized' => $normalized,
'exception' => $e->getMessage(),
]);
}
return null;
}
}

View file

@ -16,6 +16,11 @@ return [
'crm-bo-bo' => ['name' => 'ADMIN CRM > Buchungen > Buchungen', 'color' => 'admin'],
'crm-bo-le' => ['name' => 'ADMIN CRM > Buchungen > Anfragen', 'color' => 'admin'],
'crm-bo-cu' => ['name' => 'ADMIN CRM > Buchungen > Kunden', 'color' => 'admin'],
'offers-r' => ['name' => 'ADMIN CRM > Buchungen > Angebote (lesen)', 'color' => 'admin'],
'offers-w' => ['name' => 'ADMIN CRM > Buchungen > Angebote (bearbeiten)', 'color' => 'admin'],
'offers-send' => ['name' => 'ADMIN CRM > Buchungen > Angebote (versenden)', 'color' => 'admin'],
'offers-accept' => ['name' => 'ADMIN CRM > Buchungen > Angebote (Zusage / Absage / Zurückzug)', 'color' => 'admin'],
'offer-templates-w' => ['name' => 'ADMIN CRM > Buchungen > Angebotsvorlagen', 'color' => 'admin'],
'crm-cm' => ['name' => 'ADMIN CRM > Kundenverwaltung', 'color' => 'admin'],
'crm-cm-cf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)', 'color' => 'admin'],
'crm-cm-bf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)', 'color' => 'admin'],

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Contact;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact>
*/
class ContactFactory extends Factory
{
protected $model = Contact::class;
public function definition(): array
{
return [
'name' => fake()->lastName(),
'firstname' => fake()->firstName(),
'email' => fake()->unique()->safeEmail(),
'city' => fake()->city(),
'phone' => fake()->phoneNumber(),
];
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Database\Factories;
use App\Models\Contact;
use App\Models\Offer;
use App\Models\OfferVersion;
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Offer>
*/
class OfferFactory extends Factory
{
protected $model = Offer::class;
public function definition(): array
{
$y = (int) now()->format('Y');
return [
'offer_number' => $y . '-' . str_pad((string) fake()->unique()->numberBetween(1, 99_999), 5, '0', STR_PAD_LEFT),
'contact_id' => Contact::factory(),
'inquiry_id' => null,
'booking_id' => null,
'status' => Offer::STATUS_DRAFT,
'current_version_id' => null,
'created_by' => User::factory(),
];
}
public function configure(): static
{
return $this->afterCreating(function (Offer $offer) {
if ($offer->current_version_id) {
return;
}
$v = OfferVersion::query()->create([
'offer_id' => $offer->id,
'version_no' => 1,
'status' => OfferVersion::STATUS_DRAFT,
'valid_until' => null,
'total_price' => 0,
'headline' => 'Test-Angebot',
'created_by' => $offer->created_by,
]);
$offer->update(['current_version_id' => $v->id]);
});
}
public function sent(): static
{
return $this
->state(['status' => Offer::STATUS_SENT])
->afterCreating(function (Offer $offer) {
$v = $offer->refresh()->currentVersion;
if (! $v) {
return;
}
$v->update([
'status' => OfferVersion::STATUS_SENT,
'sent_at' => now(),
]);
});
}
public function accepted(): static
{
return $this
->state(['status' => Offer::STATUS_ACCEPTED])
->afterCreating(function (Offer $offer) {
$v = $offer->refresh()->currentVersion;
if (! $v) {
return;
}
$v->update([
'status' => OfferVersion::STATUS_ACCEPTED,
'sent_at' => $v->sent_at ?? now(),
'accepted_at' => now(),
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
]);
});
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Database\Factories;
use App\Models\Offer;
use App\Models\OfferItem;
use App\Models\OfferVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferItem>
*/
class OfferItemFactory extends Factory
{
protected $model = OfferItem::class;
public function definition(): array
{
$qty = fake()->numberBetween(1, 4);
$ppu = (float) fake()->randomFloat(2, 200, 4_000);
return [
'position' => 0,
'type' => OfferItem::TYPE_TRAVEL,
'title' => fake()->sentence(3),
'description' => null,
'quantity' => $qty,
'price_per_unit' => $ppu,
'total_price' => round($qty * $ppu, 2),
'travel_program_id' => null,
'fewo_lodging_id' => null,
'metadata' => null,
];
}
public function asDiscount(float $absAmount = 150): static
{
return $this->state([
'type' => OfferItem::TYPE_DISCOUNT,
'title' => 'Rabatt',
'quantity' => 1,
'price_per_unit' => -1 * abs($absAmount),
'total_price' => -1 * abs($absAmount),
]);
}
/**
* Hängt die Position an die aktuelle Version eines per Factory erstellten Angebots.
*/
public function forCurrentVersionOf(Offer $offer): static
{
$v = $offer->currentVersion;
if (! $v) {
throw new \InvalidArgumentException('Offer hat keine currentVersion — zuerst Offer::factory() erzeugen.');
}
return $this->state(fn (array $a) => [
'offer_version_id' => $v->id,
'position' => (int) $v->items()->count(),
]);
}
public function forVersion(OfferVersion $version, ?int $position = null): static
{
return $this->state(function (array $a) use ($version, $position) {
$pos = $position ?? (int) $version->items()->count();
return [
'offer_version_id' => $version->id,
'position' => $pos,
];
});
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Models\OfferItem;
use App\Models\OfferTemplate;
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferTemplate>
*/
class OfferTemplateFactory extends Factory
{
protected $model = OfferTemplate::class;
public function definition(): array
{
return [
'branch_id' => null,
'name' => 'Vorlage ' . fake()->words(2, true),
'description' => fake()->optional()->sentence(),
'default_headline' => fake()->sentence(4),
'default_intro' => '<p>' . fake()->paragraph() . '</p>',
'default_itinerary' => null,
'default_closing' => null,
'default_items' => [
[
'type' => OfferItem::TYPE_TRAVEL,
'title' => 'Reiseleistung (Standard)',
'quantity' => 2,
'price_per_unit' => 1_250.00,
],
],
'is_active' => true,
'created_by' => User::factory(),
];
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace Database\Factories;
use App\Models\Offer;
use App\Models\OfferVersion;
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferVersion>
*/
class OfferVersionFactory extends Factory
{
protected $model = OfferVersion::class;
public function definition(): array
{
// `Offer::factory()` legt V1 in OfferFactory::afterCreating an — V2+ ist
// pro (offer_id, version_no) unique.
return [
'offer_id' => Offer::factory(),
'version_no' => 2,
'status' => OfferVersion::STATUS_DRAFT,
'valid_until' => null,
'total_price' => fake()->randomFloat(2, 100, 15_000),
'headline' => fake()->sentence(4),
'intro_text' => '<p>' . fake()->paragraph() . '</p>',
'itinerary_text' => null,
'closing_text' => null,
'template_id' => null,
'pdf_path' => null,
'pdf_archived' => false,
'sent_at' => null,
'accepted_at' => null,
'accepted_via' => null,
'template_document_ids' => null,
'created_by' => User::factory(),
];
}
public function forOffer(Offer $offer, int $versionNo = 2): static
{
return $this->state(function (array $a) use ($offer, $versionNo) {
return [
'offer_id' => $offer->id,
'version_no' => $versionNo,
'created_by' => $offer->created_by,
];
});
}
public function versionSent(): static
{
return $this->state([
'status' => OfferVersion::STATUS_SENT,
'sent_at' => now(),
]);
}
public function versionAccepted(): static
{
return $this->state([
'status' => OfferVersion::STATUS_ACCEPTED,
'sent_at' => now(),
'accepted_at' => now(),
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
]);
}
}

View file

@ -8,7 +8,7 @@ use Illuminate\Database\Migrations\Migration;
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
* @see https://github.com/cviebrock/sequel-pro-laravel-export
*/
class CreateBookingVoucherTable extends Migration
class CreateBookingVoucherAgencyTable extends Migration
{
/**
* Run the migrations.

View file

@ -1,47 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
/**
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
* @see https://github.com/cviebrock/sequel-pro-laravel-export
*/
class CreateBookingVoucherTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('booking_voucher', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('booking_id');
$table->binary('binary_data');
$table->dateTime('created_at');
$table->dateTime('updated_at');
$table->index('booking_id', 'booking_voucher_booking_id_idx');
$table->foreign('booking_id', 'booking_voucher_booking_id_booking_id')->references('id')->on('booking')->onDelete('CASCADE
')->onUpdate('RESTRICT');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('booking_voucher');
}
}

View file

@ -28,19 +28,14 @@ return new class extends Migration
$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();
// FK-Typen MÜSSEN exakt zur Referenz passen.
// Die Legacy-Tabellen contacts/inquiries/booking sind aus dem
// alten Sequel-Pro-Export mit `bigint` (SIGNED) angelegt; die
// users-Tabelle nutzt `int unsigned`. Daher hier explizit
// typisieren statt foreignId() (das würde bigint UNSIGNED erzeugen).
$t->bigInteger('contact_id');
$t->bigInteger('inquiry_id')->nullable();
$t->bigInteger('booking_id')->nullable();
$t->enum('status', [
'draft',
@ -54,11 +49,17 @@ return new class extends Migration
// FK wird in 2026_04_17_100007 nachträglich gesetzt
$t->unsignedBigInteger('current_version_id')->nullable();
$t->foreignId('created_by')->constrained('users');
// users.id ist int unsigned (Legacy)
$t->unsignedInteger('created_by');
$t->timestamps();
$t->softDeletes();
$t->foreign('contact_id')->references('id')->on('contacts')->restrictOnDelete();
$t->foreign('inquiry_id')->references('id')->on('inquiries')->nullOnDelete();
$t->foreign('booking_id')->references('id')->on('booking')->nullOnDelete();
$t->foreign('created_by')->references('id')->on('users');
$t->index(['status', 'contact_id']);
$t->index('inquiry_id');
});

View file

@ -65,10 +65,13 @@ return new class extends Migration
// die mit dieser Version (als Anhang) verknüpft sind
$t->json('template_document_ids')->nullable();
$t->foreignId('created_by')->constrained('users');
// users.id ist int unsigned (Legacy) → siehe Migration 100001
$t->unsignedInteger('created_by');
$t->timestamps();
$t->foreign('created_by')->references('id')->on('users');
$t->unique(['offer_id', 'version_no']);
$t->index(['offer_id', 'status']);
});

View file

@ -26,10 +26,8 @@ return new class extends Migration
// `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();
// FK-Typen: branch.id ist bigint signed, users.id int unsigned.
$t->bigInteger('branch_id')->nullable();
$t->string('name');
$t->text('description')->nullable();
@ -43,11 +41,14 @@ return new class extends Migration
$t->json('default_items')->nullable();
$t->boolean('is_active')->default(true);
$t->foreignId('created_by')->constrained('users');
$t->unsignedInteger('created_by');
$t->timestamps();
$t->softDeletes();
$t->foreign('branch_id')->references('id')->on('branch')->nullOnDelete();
$t->foreign('created_by')->references('id')->on('users');
$t->index(['branch_id', 'is_active']);
});

View file

@ -0,0 +1,115 @@
<?php
namespace Database\Seeders;
use App\Models\OfferItem;
use App\Models\OfferTemplate;
use App\User;
use Illuminate\Database\Seeder;
/**
* Legt 5 Angebots-Vorlagen für manuelle/QA-Tests an.
*
* Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`
* (benötigt mindestens einen User in `users` und ausgeführte Offer-Migrationen).
*/
class OfferTemplateSeeder extends Seeder
{
public function run(): void
{
$userId = User::query()->orderBy('id')->value('id');
if (! $userId) {
$this->command?->warn('OfferTemplateSeeder: kein User — übersprungen.');
return;
}
$templates = [
[
'name' => 'Südafrika Klassik (14 Tage)',
'description' => 'Kombirundreise, Baustein für B2-Modal-Tests',
'default_headline' => 'Ihr Südafrika-Erlebnis',
'default_intro' => '<p>Sehr geehrte Gäste, wir freuen uns, Ihnen folgendes Reiseangebot zu unterbreiten.</p>',
'default_items' => [
[
'type' => OfferItem::TYPE_TRAVEL,
'title' => 'Rundreise 14 Tage inkl. Mietwagen',
'quantity' => 2,
'price_per_unit' => 1_800.00,
],
[
'type' => OfferItem::TYPE_OPTION,
'title' => 'Business-Class-Aufpreis (Flug)',
'quantity' => 2,
'price_per_unit' => 450.00,
],
],
],
[
'name' => 'Namibia Self-Drive',
'default_headline' => 'Namibia per Mietwagen',
'default_items' => [
[
'type' => OfferItem::TYPE_TRAVEL,
'title' => 'Mietwagen 10 Tage',
'quantity' => 1,
'price_per_unit' => 890.00,
],
],
],
[
'name' => 'Mauritius Strand (8 Nächte)',
'default_headline' => 'Honeymoon-Insel',
'default_items' => [
[
'type' => OfferItem::TYPE_TRAVEL,
'title' => 'Hotel 5* Halbpension (p.P.)',
'quantity' => 2,
'price_per_unit' => 1_200.00,
],
],
],
[
'name' => 'Kurzangebot Städtereise',
'default_headline' => 'Wochenend-Trip',
'default_items' => [
[
'type' => OfferItem::TYPE_SERVICE,
'title' => 'Transfer Flughafen',
'quantity' => 1,
'price_per_unit' => 75.00,
],
],
],
[
'name' => 'Rabatt-Template (Frühbucher)',
'default_headline' => 'Frühbucher-Sonderkonditionen',
'default_items' => [
[
'type' => OfferItem::TYPE_TRAVEL,
'title' => 'Basispaket',
'quantity' => 2,
'price_per_unit' => 1_000.00,
],
[
'type' => OfferItem::TYPE_DISCOUNT,
'title' => 'Frühbucher-Rabatt',
'quantity' => 1,
'price_per_unit' => -200.00,
],
],
],
];
foreach ($templates as $t) {
OfferTemplate::query()->updateOrCreate(
['name' => $t['name']],
array_merge($t, [
'branch_id' => null,
'is_active' => true,
'created_by' => $userId,
])
);
}
}
}

View file

@ -1,10 +1,11 @@
# Umsetzung: Neustrukturierung Customer / Lead / Booking
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Phase 2 auf **Test** erfolgreich migriert und verifiziert — bereit für Live-Deploy.
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Hotfix gegen Import-Bug ausgespielt (2026-04-18); Phase 2 auf **Test** erfolgreich migriert und verifiziert — Phase-2-Live-Deploy nach Stabilitätsphase.
**Erstellt:** April 2025
**Letzte Aktualisierung:** 2026-04-17
**Letzte Aktualisierung:** 2026-04-18
**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)
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
**Hotfix-Dokumentation:** [../hotfix-2026-04-18/HOTFIX.md](../hotfix-2026-04-18/HOTFIX.md)
---
@ -18,6 +19,7 @@
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ✅ Abgeschlossen | ✅ **Ja, Batch 2729** | ⬜ Nein (Deploy-Handbuch fertig) |
| Phase 2 — Smoke-Test-Fixes 2026-04-17 (`Lead::bookings()` FK; `orderColumn`-SQL in 4 Controllern; Blade-Column-`name:` in `contact/index.blade.php`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt) |
| Phase-1-Hotfix — nullable Parameter (`?int $mailDirId`, `?string $subdir`) in 3 Service-Klassen (Mail-Dialoge mit NULL-Werten) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt, siehe Handbuch) |
| **Hotfix 2026-04-18**`BookingImport.php` auf Live: `'inquiry_id' → 'lead_id'` (Phase-2-Datei versehentlich beim Phase-1-Deploy gerutscht, neue Bookings mit `lead_id=NULL`); Reparatur-DB-Query + 2 Artisan-Commands (`bookings:resync-from-travel-bookings`, `bookings:repair-from-travel-bookings`) | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-18** |
| 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 |

View file

@ -0,0 +1,172 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\Booking;
use App\Models\Customer;
use App\Models\Arrangement;
use App\Models\Participant;
use App\Models\TravelBooking;
use App\Models\BookingServiceItem;
use App\Repositories\DraftRepository;
class BookingImport
{
public static function importFrom(TravelBooking $travel_booking){
// ---- createCustomer
$data = [
'salutation_id' => $travel_booking->salutation_id,
'name' => $travel_booking->last_name,
'firstname' => $travel_booking->first_name,
'street' => $travel_booking->street,
'zip' => $travel_booking->zipcode,
'city' => $travel_booking->city,
'country_id' => $travel_booking->country_id,
'phone' => $travel_booking->phone,
'phonemobile' => $travel_booking->mobile,
'email' => $travel_booking->email
];
$customer = Customer::create($data);
// ---- createLead
$data = [
'customer_id' => $customer->id,
'request_date' => $travel_booking->created,
'travelperiod_start' => $travel_booking->selected_start_date,
'travelperiod_end' => $travel_booking->selected_end_date,
'remarks' => $travel_booking->comments,
'sf_guard_user_id' => 15,
'is_closed' => true,
'initialcontacttype_id' => 14,
'status_id' => 7,
'website_id' => 1,
];
$lead = Lead::create($data);
$lead->updateNextDueDate();
$comfort = false;
if(isset($travel_booking->drafts['comfort']) && $travel_booking->drafts['comfort']){
$comfort = true;
}
$data = [
'booking_date' => $travel_booking->created->format('Y-m-d'),
'customer_id' => $customer->id,
'lead_id' => $lead->id,
'new_drafts' => $travel_booking->drafts === null ? 0 : 1,
'sf_guard_user_id' => 15,
'branch_id' => 4,
'pax' => $travel_booking->selected_adults,
'title' => isset($travel_booking->selected_travel['travel_title']) ? $travel_booking->selected_travel['travel_title'] : "",
'comfort' => $comfort,
'start_date' => $travel_booking->selected_start_date->format('Y-m-d'),
'end_date' => $travel_booking->selected_end_date->format('Y-m-d'),
'website_id' => 1,
'travel_number' => isset($travel_booking->selected_travel['travel_number']) ? $travel_booking->selected_travel['travel_number'] : null,
/*'participant_name' => isset($travel_booking->participants[0]['last_name']) ? $travel_booking->participants[0]['last_name'] : null,
'participant_firstname' => isset($travel_booking->participants[0]['first_name']) ? $travel_booking->participants[0]['first_name'] : null,
'participant_birthdate' => isset($travel_booking->participants[0]['birthday']) ? date( "Y-m-d", strtotime($travel_booking->participants[0]['birthday'])) : null,
'participant_salutation_id' => isset($travel_booking->participants[0]['gender']) ? $travel_booking->participants[0]['gender'] : null,
'nationality_id' => isset($travel_booking->participants[0]['nationality']) ? $travel_booking->participants[0]['nationality'] : null,*/
'participant_name' => null,
'participant_firstname' => null,
'participant_birthdate' => null,
'participant_salutation_id' => null,
'nationality_id' => null,
'price' => $travel_booking->price,
'price_total' => $travel_booking->price_total,
'deposit_total' => $travel_booking->deposit_total,
'final_payment' => $travel_booking->final_payment,
'final_payment_date' => $travel_booking->final_payment_date->format('Y-m-d'),
'travel_country_id' => isset($travel_booking->selected_travel['travel_country_id'][0]) ? $travel_booking->selected_travel['travel_country_id'][0] : null,
'travel_category_id' => isset($travel_booking->selected_travel['travel_category_id']) ? $travel_booking->selected_travel['travel_category_id'] : null,
'travelagenda_id' => isset($travel_booking->selected_travel['travelagenda_id']) ? $travel_booking->selected_travel['travelagenda_id'] : null,
'travel_company_id' => isset($travel_booking->selected_travel['travel_company_id']) ? $travel_booking->selected_travel['travel_company_id'] : 4,
'insurance_offer' => $travel_booking->insurance_offer,
];
//createBooking
$booking = Booking::create($data);
//createTraveler
if($travel_booking->participants){
foreach ($travel_booking->participants as $key => $participant){
Participant::create([
'booking_id' => $booking->id,
'participant_name' => $participant['last_name'],
'participant_firstname' => $participant['first_name'],
'participant_birthdate' => date( "Y-m-d", strtotime($participant['birthday'])),
'participant_salutation_id' => $participant['gender'],
'participant_child' => $participant['child'],
'nationality_id' =>$participant['nationality'],
]);
}
}
//createServiceItem //service_items
if($travel_booking->service_items){
foreach ($travel_booking->service_items as $key => $service_item){
BookingServiceItem::create([
'booking_id' => $booking->id,
'travel_company_id' => $service_item['travel_company_id'],
'service_price' => $service_item['service_price'],
'service_price_refund' => 0,
'commission' => $service_item['commission'],
'travel_date' => $service_item['travel_date'],
'name' => $service_item['name'],
'is_commission_locked' => 0,
]);
}
}
//createServiceItem //insurances
if($travel_booking->insurances){
foreach ($travel_booking->insurances as $service_item){
BookingServiceItem::create([
'booking_id' => $booking->id,
'travel_company_id' => $service_item['travel_company_id'],
'service_price' => $service_item['price'],
'service_price_refund' => 0,
'commission' => $service_item['commission'],
'travel_date' => $service_item['travel_date'],
'name' => $service_item['name'],
'is_commission_locked' => 0,
]);
}
}
//createArrangement
if($travel_booking->arrangements) {
foreach ($travel_booking->arrangements as $key => $arrangement){
Arrangement::create([
'state' => isset($arrangement['state']) ? $arrangement['state'] : null,
'begin' => isset($arrangement['end']) ? $arrangement['end'] : null,
'end' => isset($arrangement['end']) ? $arrangement['end'] : null,
'type_s' => isset($arrangement['type_s']) ? $arrangement['type_s'] : null,
'data_s' => isset($arrangement['data_s']) ? $arrangement['data_s'] : null,
'view_position' => isset($arrangement['view_position']) ? $arrangement['view_position'] : null,
'booking_id' => $booking->id,
'type_id' => $arrangement['type_id'],
'in_pdf' => isset($arrangement['in_pdf']) ? $arrangement['in_pdf'] : null,
]);
}
}
//createDrafts
if($travel_booking->drafts) {
$draftRepo = new DraftRepository($booking);
$draftRepo->create_drafts_from_booking($booking->id, $travel_booking->drafts);
}
$travel_booking->crm_booking_id = $booking->id;
$travel_booking->save();
return $booking;
}
}

View file

@ -0,0 +1,254 @@
# Hotfix 2026-04-18 — Fehlende `booking.lead_id` bei neu importierten Buchungen
**Status:** Dokumentation des am 2026-04-17/18 aufgetretenen Live-Bugs und der durchgeführten Korrekturen.
**Zielsystem:** Live (`mein.sterntours.de`, Phase 1)
**Abhängigkeit:** Phase-1-Deploy vom 2026-04-17 — NICHT vermischen mit Phase-2-Deploy.
---
## 1. Symptom
Auf Live wurden ab dem 2026-04-17 neu eingehende Anfragen über die API zwar in
`contact` (ehem. `customer`) und `lead` sauber angelegt, aber das zugehörige
`booking` blieb in den alten Views „frei schwebend":
- Die Buchung war im Kontakt-Detail sichtbar (über `booking.customer_id`).
- In der Lead-Ansicht tauchte sie NICHT auf (`lead -> bookings`).
- In der Booking-Liste wurde die Verknüpfung zum Lead nicht angezeigt.
- In der DB: `booking.lead_id = NULL` bei jedem neuen Datensatz seit Phase-1-Deploy.
Zusätzlich trat in Einzelfällen auf, dass ein importiertes Booking keine
Programm-Verknüpfung hatte (`title`, `travel_number`, `travelagenda_id` etc. leer).
## 2. Root Cause
Beim Phase-1-Deploy am 2026-04-17 ist die **Phase-2-Version** von
`app/Services/BookingImport.php` versehentlich mit auf Live gelandet. Diese
Datei enthält die für Phase 2 vorbereitete Zeile:
```diff
- 'lead_id' => $lead->id, // Phase 1 (korrekt für Live)
+ 'inquiry_id' => $lead->id, // Phase 2 (Workspace-Stand)
```
Auf Live existiert die Spalte `booking.inquiry_id` (noch) nicht — sie entsteht
erst in Phase 2 durch das Rename `booking.lead_id → booking.inquiry_id`. Und
das Live-`Booking`-Model hat `'inquiry_id'` nicht in `$fillable`. Folge:
- `Booking::create(['inquiry_id' => 42, ...])` → Eloquent ignoriert das Feld
stumm (mass-assignment guard).
- `customer_id` wird trotzdem korrekt gesetzt → Kontakt sieht die Buchung.
- `lead_id` bleibt `NULL` → alle leadbasierten Views finden die Buchung nicht.
Warum Programm-Felder in Einzelfällen fehlten, ist davon unabhängig: wenn
`travel_booking.selected_travel` zum Import-Zeitpunkt leer/unvollständig war,
übernimmt `BookingImport` die sechs Programm-Felder (`title`, `travel_number`,
`travel_country_id`, `travel_category_id`, `travelagenda_id`,
`travel_company_id`) als `null`.
## 3. Verifikation des Root Cause
Git-Commit `5a74789 deplay phase 1` vs. aktueller Workspace-Stand
(`ba48745 phase 2 dev`):
```bash
cd /workspace/mein.sterntours.de
git diff 5a74789 HEAD -- app/Services/BookingImport.php
# → Zeigt genau die `'lead_id'``'inquiry_id'` Zeile.
```
Auf Live gegengecheckt mit:
```bash
grep -n "lead_id\|inquiry_id" app/Services/BookingImport.php
# → Live zeigte 'inquiry_id' (Bug bestätigt)
```
## 4. Sofort-Hotfix (erledigt)
### 4.1 Datei-Austausch
Die saubere Phase-1-Version von `BookingImport.php` wurde aus Commit `5a74789`
exportiert und liegt unter:
```
dev/hotfix-2026-04-18/BookingImport.php
```
Upload:
```bash
scp dev/hotfix-2026-04-18/BookingImport.php \
user@live:/var/www/html/app/Services/BookingImport.php
# Auf Live:
php artisan cache:clear
php artisan view:clear
php artisan config:clear
composer dump-autoload
```
### 4.2 DB-Reparatur für bereits verwaiste Buchungen
Vorschau-Query:
```sql
SELECT COUNT(*) FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17';
SELECT b.id AS booking_id, b.customer_id, b.created_at AS booking_created,
l.id AS lead_candidate_id, l.created_at AS lead_created,
c.firstname, c.name, c.email
FROM booking b
LEFT JOIN lead l ON l.customer_id = b.customer_id
LEFT JOIN customer c ON c.id = b.customer_id
WHERE b.lead_id IS NULL
AND b.created_at >= '2026-04-17'
ORDER BY b.id DESC;
-- Sanity-Check: eindeutige 1:1-Zuordnung Customer ↔ Lead?
SELECT customer_id, COUNT(*) AS lead_count
FROM lead
WHERE customer_id IN (
SELECT customer_id FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17'
)
GROUP BY customer_id
HAVING lead_count > 1;
```
Reparatur (nur wenn Sanity-Check leer):
```sql
UPDATE booking b
INNER JOIN lead l ON l.customer_id = b.customer_id
SET b.lead_id = l.id
WHERE b.lead_id IS NULL
AND b.created_at >= '2026-04-17';
```
Zwei betroffene Buchungen wurden am 2026-04-18 so manuell nachgetragen (vom User).
## 5. Reparatur-Toolkit für zukünftige Ausfälle
Zwei Artisan-Commands wurden gebaut, um ähnliche Import-Probleme gezielt
abhandeln zu können (siehe `app/Console/Commands/`). Beide sind **nicht Teil
von Phase 2** — reine Live-Werkzeuge, die sowohl auf Phase 1 als auch auf
Phase 2 funktionieren (sie nutzen das bestehende `BookingImport`).
### 5.1 `bookings:resync-from-travel-bookings`
**Use Case:** `travel_booking.crm_booking_id IS NULL` — Import ist nie
durchgelaufen oder mittendrin abgebrochen. Zieht das komplette Booking
(Customer + Lead + Booking + Participants + Arrangements + ServiceItems +
Drafts) frisch nach.
```bash
# Dry-Run (immer zuerst):
php artisan bookings:resync-from-travel-bookings --days=14
# Mit Anwenden:
php artisan bookings:resync-from-travel-bookings --days=14 --apply
# Einzelfall:
php artisan bookings:resync-from-travel-bookings --id=12345 --apply
# Plus Orphan-Check:
php artisan bookings:resync-from-travel-bookings --days=30 --include-orphans
```
Jeder Import in eigener Transaktion — Fehler in einem Datensatz stoppt die
anderen nicht.
### 5.2 `bookings:repair-from-travel-bookings`
**Use Case:** `crm_booking_id` ist gesetzt, aber einzelne Programm-Felder im
Booking fehlen (z. B. `title`, `travel_number`, `travelagenda_id`). Macht nur
ein Partial-Update der sechs Programm-Felder.
| booking-Spalte | Quelle in `travel_booking.selected_travel` |
|---|---|
| `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` |
```bash
# Dry-Run:
php artisan bookings:repair-from-travel-bookings --days=14
# Anwenden:
php artisan bookings:repair-from-travel-bookings --days=14 --apply
# Gezielt auf CRM-Booking-ID:
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply
# Auch bereits gesetzte Felder überschreiben:
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply --force
```
Default-Policy: **nur leere/NULL-Felder füllen**. Manuell gesetzte Werte
bleiben unangetastet. Mit `--force` werden auch bestehende Werte überschrieben.
### 5.3 Entscheidungshilfe
| Situation | Tool |
|---|---|
| `travel_booking.crm_booking_id IS NULL` | `bookings:resync-from-travel-bookings` |
| Buchung existiert, nur Programm-Felder leer | `bookings:repair-from-travel-bookings` |
| Buchung existiert, aber viele Felder / Teilnehmer fehlen | CRM-Booking löschen → `travel_booking.crm_booking_id` auf NULL → `bookings:resync-…` |
## 6. Deploy dieser Hotfix-Tools auf Live
Beide Commands sind selbst-enthaltene PHP-Dateien, keine DB-Änderung nötig:
```bash
scp app/Console/Commands/BookingsResyncFromTravelBookings.php \
user@live:/var/www/html/app/Console/Commands/BookingsResyncFromTravelBookings.php
scp app/Console/Commands/BookingsRepairFromTravelBookings.php \
user@live:/var/www/html/app/Console/Commands/BookingsRepairFromTravelBookings.php
# Auf Live:
php artisan list | grep "bookings:"
# → muss beide Commands zeigen
```
## 7. Konsequenzen für Phase-2-Deploy
Dieser Vorfall hat gezeigt, dass beim Phase-1-Deploy am 2026-04-17 mindestens
eine Phase-2-Datei mit rübergerutscht ist. Vor Phase-2-Deploy MUSS daher
zusätzlich zum bestehenden `UPLOAD-LIST`-Verfahren ein expliziter Scan
stattfinden — siehe Ergänzung im Phase-2-Deploy-Handbuch.
**Sanity-Check vor jedem zukünftigen Phase-Deploy:**
```bash
# Auf Live, nach dem Upload:
grep -rn "inquiry_id\|'contacts'\b\|'inquiries'\b" app/ 2>/dev/null | head -40
```
Wenn nach Phase-2-Deploy alle Treffer auf die erwarteten Stellen zeigen (und
nicht auf nicht-gelistete Dateien), ist der Deploy sauber. Ein analoger
Gegencheck vor Phase 2 (dort dürfen keine Phase-2-Tokens existieren außer
in `BookingImport.php`, falls dieser Hotfix-Stand noch nicht ersetzt wurde).
---
## Anhang: Inhalt dieses Ordners
```
dev/hotfix-2026-04-18/
├── HOTFIX.md ← Dieses Dokument
└── BookingImport.php ← Phase-1-Version aus Commit 5a74789 (Deploy-Ready)
```
Die beiden neuen Artisan-Commands liegen im normalen Code-Baum:
```
app/Console/Commands/BookingsResyncFromTravelBookings.php
app/Console/Commands/BookingsRepairFromTravelBookings.php
```
Sie gehören dauerhaft zum Projekt und sollen auch nach Phase-2-Deploy
bestehen bleiben (sie funktionieren auf beiden Schemas).

View file

@ -1,16 +1,127 @@
# Modul 6 — Angebote: Implementierungs-Tickets
**Status:** Entwurf, bereit zur Umsetzung
**Status:** In Umsetzung (A7 abgeschlossen, Phase B als Nächstes)
**Erstellt:** April 2026
**Letzte Aktualisierung:** 2026-04-24
**Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md)
**Abhängigkeiten-Check vor Start:**
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren.
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`.
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren. ✅ Test
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`. ⬜ Fallback gewählt
- Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen.
- Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar.
---
## Fortschritt
| Ticket | Status | Datum | Notizen |
|--------|--------|-------|---------|
| A1 — Datenbank-Migrationen (7 Files) | ✅ Test (Batch 34) | 2026-04-18 | FK-Typ-Fix nötig, siehe A1-Lessons |
| A2 — Eloquent-Models (6 Files) | ✅ Test | 2026-04-18 | Smoke-Test grün, siehe A2-Lessons |
| A3 — Repositories (4 Files) | ✅ Test | 2026-04-18 | Smoke-Test 9/9 grün, siehe A3-Lessons |
| A4 — OfferService + Exceptions + Events | ✅ Test | 2026-04-18 | Smoke-Test 13/13 grün, siehe A4-Lessons |
| A5 — FormRequests (4 Files) | ✅ | 2026-04-18 | `app/Http/Requests/Offer/*`, siehe A5-Lessons |
| A6 — Routing + Permissions + Sidenav | ✅ | 2026-04-24 | 15 Routen, 5 Rechte, siehe A6-Lessons |
| A7 — Factories + Seeder | ✅ | 2026-04-24 | `ContactFactory` + 4 Offer-Factories + `OfferTemplateSeeder`, siehe A7-Lessons |
### A1 — Lessons Learned (Test-Migration 2026-04-18)
1. **FK-Typ-Mismatch:** Spec/erste Version nutzte `foreignId(...)``bigint unsigned`. Die Legacy-Tabellen haben aber:
- `contacts.id`, `inquiries.id`, `booking.id`, `branch.id``bigint` **(signed!)**
- `users.id``int unsigned`
Fix in 3 Migrationen (100001, 100002, 100004): explizit `$t->bigInteger('contact_id')` etc. + manueller `$t->foreign(...)->references('id')->on('xxx')`. Für `users``unsignedInteger('created_by')`.
2. **Migrations-Pfad-Trick:** Das Projekt hat viele alte Pending-Migrationen, deren Tabellen längst existieren (Sequel-Pro-Export-Historie). `php artisan migrate --force` (ohne Pfad) schlägt deswegen fehl. Workaround:
```bash
mkdir -p storage/migrations-tmp/offers
for f in database/migrations/2026_04_17_*.php; do
ln -sf "$(realpath $f)" "storage/migrations-tmp/offers/$(basename $f)"
done
php artisan migrate --path=storage/migrations-tmp/offers --realpath --force
```
So werden ausschließlich die 7 Offer-Files geladen.
3. **Class-Name-Bug entdeckt & gefixt:** `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php` deklarierte fälschlicherweise `class CreateBookingVoucherTable` (richtig: `CreateBookingVoucherAgencyTable`). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen.
4. **Verifikation OK:** 15 neue FKs gesetzt (5 auf `offers`, 3 auf `offer_versions`, je 12 auf andere + 1 Reverse auf `booking.offer_id`), Indizes/Unique-Constraints alle vorhanden. Siehe `SHOW CREATE TABLE`-Outputs in den Smoke-Tests.
5. **Akzeptanzkriterium „migrate:fresh --seed reproduzierbar":** Auf Test nicht ausführbar (würde DB löschen). Empfehlung: in CI auf einer Throwaway-DB testen, sobald CI-Pipeline existiert.
### Live-Deploy von A1 (TODO später)
Wenn A2-A7 fertig sind und das Modul deploybar ist, müssen für Live mit:
- 7 Migrationen aus `database/migrations/2026_04_17_100001` bis `100007`
- 1 Klassennamen-Fix aus `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php`
ein eigenes Live-Deploy-Handbuch geschrieben werden (analog `phase-2-live-deploy.md`).
### A2 — Lessons Learned (2026-04-18)
1. **6 Models angelegt**: `Offer`, `OfferVersion`, `OfferItem`, `OfferTemplate`, `OfferFile`, `OfferAccessToken` in `app/Models/`. Alle mit `HasFactory`, `$connection='mysql'`, typisierten PHPDoc-Annotations, `STATUS_*`-Konstanten (statt String-Literals) und sauberen `BelongsTo`/`HasMany`-Relations.
2. **Status-Konstanten:** Stati sind als `self::STATUS_DRAFT` etc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl. `superseded`). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet und `whereIn` mit Strings gut funktioniert.
3. **OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self** erzeugt `Str::random(64)`-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Property `plain_token`. Counterpart `findActiveByPlainToken(string)` hasht den eingehenden Klartext und scoped auf `active()` (nicht widerrufen, nicht abgelaufen).
4. **OfferFile API-kompatibel zu BookingFile**: Gleiche Method-Signaturen (`getURL`, `getPath`, `getIconExt`, `formatBytes`) — so können Blade-Partials/Dropzone-Helper aus dem Booking-Modul geteilt werden. Unterschied: Spalte `include_in_pdf` statt `frozen` (semantisch klarer).
5. **Smoke-Test (Tinker, in Transaktion + Rollback)**: Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading `currentVersion.items / .tokens / contact` grün, `totalPriceFormatted() → "1.234,56 €"`, `isEditable() → true` für Draft.
6. **Offer-Inquiry-Relation zeigt auf Lead-Model**: Die Tabelle heißt nach Phase 2 `inquiries`, das Eloquent-Model ist aber weiterhin `App\Models\Lead` (Umbenennung des Models ist eigenes Ticket). Offer verwendet `$this->belongsTo(Lead::class, 'inquiry_id')`.
### A3 — Lessons Learned (2026-04-18)
1. **4 Repositories angelegt**: `OfferRepository`, `OfferVersionRepository`, `OfferTemplateRepository` (extends `BaseRepository`) und `OfferFileRepository` (extends `FileRepository`). Alle mit `use Illuminate\Support\Facades\DB;` — konsistent mit Phase-1-Stil der `ContactsMergeDuplicates`-Command.
2. **`generateOfferNumber()` race-safe umgesetzt**: `lockForUpdate()` auf der höchsten `offer_number LIKE '{year}-%'`-Zeile innerhalb einer `DB::transaction`; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordert `YYYY-NNNNN`.
3. **`OfferRepository::create()` atomar**: Offer + Version#1 in einem `DB::transaction`-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.
4. **`createNewVersion()` dupliziert via `replicate()`**: Methode nutzt Eloquents `replicate(['except-cols'])`, um Items + Files als neue Zeilen zu kopieren. Physische Datei-Binaries werden **nicht** dupliziert — die Datei-Zeile bekommt nur eine neue DB-ID. Falls später einzelne Versionen gelöscht werden, muss die Delete-Logik prüfen, ob andere Versionen noch auf dieselbe Datei referenzieren (Aufgabe für B4/B5).
5. **`updateDraft()` wirft `DomainException` bei eingefrorener Version**: Smoke-Test 7 bestätigt das saubere Verhalten. Item-Sync ist destruktiv: IDs aus `$data['items']` werden aktualisiert, fehlende IDs neu angelegt, nicht mehr enthaltene Items gelöscht. `total_price` wird danach aus `SUM(items.total_price)` neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.
6. **`OfferTemplate::applyTo()` ist behutsam**: Überschreibt nur **leere** Text-Felder (`?: $template->default_xxx`); Items der Version werden hingegen komplett ersetzt (das ist der Sinn einer Vorlage). Property-Access-Warnungen für globale `now()`/`\Storage`-Facades haben wir mit `Carbon::now()` bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.
7. **Smoke-Test 9/9 grün**: Nummer generieren / atomares create / findByNumber / updateDraft mit Items (Summe 5.400 €) / Item-Sync (5.278 €) / markSent (+ Offer-Status auf sent) / updateDraft auf Sent wirft korrekt / createNewVersion (V1→superseded, V2 mit 3 kopierten Items, current_version umgeschoben, Offer zurück auf draft) / Template-anwenden (leere Texte übernommen, bestehender headline geschützt, 2 neue Items, Summe 2.119 €). Alles in einer Transaktion + Rollback.
### A4 — Lessons Learned (2026-04-18)
1. **`OfferService` mit DI-Konstruktor** (4 Repos injected). Kein Static-Helper wie `App\Services\Booking` — der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.
2. **2 Custom Exceptions, sprechend:**
- `OfferNotEditableException(OfferVersion $v, string $action)` — trägt `versionId` + `currentStatus` als readonly Properties, Controller können fein unterscheiden.
- `InvalidOfferStatusException(Offer $o, array $expected, string $action)` — trägt `offerId`, `currentStatus`, `expectedStatuses[]`.
3. **4 Domain-Events** (`OfferSent`, `OfferAccepted`, `OfferDeclined`, `OfferWithdrawn`) in `app/Events/Offer/`. Dispatchen per `Event::dispatch()` — die Mail/Audit-Log-Arbeit machen Listener in Ticket B6 und später Modul 3 Phase 4 (Communications). Der Service selbst weiß nichts von Mail oder `customer_mails`.
4. **`send()` isoliert den Token-Lifecycle**: Erzeugt frischen Access-Token in derselben Transaktion wie den Status-Wechsel. Falls später die Mail scheitert, wird der Event-Listener die Queue-Retry machen — der Status-Übergang bleibt gültig. Guard: nur auf der `current_version_id`-Version, nur im Status `draft`.
5. **`supersede()` revoked alte Tokens**: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Call `revoked_at IS NOT NULL` hat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.
6. **`markDeclined()` akzeptiert `sent` UND `accepted`**: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend auf `declined` gesetzt.
7. **`purgeExpired()` respektiert Offer-Hierarchie**: Setzt nur dann auch den Offer auf `expired`, wenn der Offer (a) noch `sent` ist und (b) die expirierte Version die `current_version_id` ist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).
8. **`convertToBooking()` nur als Guards+Stub**: Wirft sauber `InvalidOfferStatusException` (not accepted) und `DomainException` (double-invoke), aber der eigentliche Booking-Create hängt an `BookingService::createManual()` aus Modul 4 und wird in Ticket B8 finalisiert.
9. **Smoke-Test 13/13 grün**: createBlank + createBlank-mit-Template / updateVersion / send+Token / 2× Editable-Guard (updateVersion & send auf sent) / markAccepted + Double-Accept-Guard / supersede+Token-Revoke / withdraw + Double-Withdraw-Guard / convertToBooking-Guard / purgeExpired — alles in Transaktion + Rollback.
10. **Echte PHPUnit-Unit-Tests (Akzeptanzkriterium aus Spec) werden in Ticket F4 angelegt** (dort ist Coverage-Ziel ≥ 80 % für Service+Repo vereinbart). Der aktuelle Tinker-Smoke-Test liefert aber alle Pfad-Abdeckungen als Runbook, die F4 als Vorlage nutzen kann.
### A5 — Lessons Learned (2026-04-18)
1. **Erste `FormRequest`s im CRM-Projekt** (vorher keine `app/Http/Requests/`-Klasse). Muster: `Illuminate\Foundation\Http\FormRequest`, `authorize() =>` eingeloggter User, `rules()`, ggf. `withValidator` / `prepareForValidation`.
2. **StoreOfferRequest — Kreuz-Validierung**: Mindestens eines von `contact_id` / `inquiry_id` muss gesetzt sein (`withValidator` + Fehler auf `contact_id`). `template_id` mit `Rule::exists('offer_templates','id')->whereNull('deleted_at')` (keine gelöschte Vorlage).
3. **UpdateVersionRequest — WYSIWYG**: `intro_text` / `itinerary_text` / `closing_text` mit `strip_tags` + Whitelist; `headline` ohne Whitelist. Kein Mews/HtmlPurifier in `composer.json` — bei Bedarf nachziehen.
4. **Preise in Positionen**: `items.*.price_per_unit` = `numeric`; in `withValidator` für Typen außer `discount` Wert `>= 0` erzwingen.
5. **valid_until**`after_or_equal:today` (nicht in der Vergangenheit). **SendOfferRequest** `expires_at``after:now` (für E-Mail-Token echter Zeitpunkt in der Zukunft).
6. **cc/bcc** als langer String; Aufteilen in einzelne Adressen erst im Mail-Listener (B6).
7. **Datei-Upload-Regeln (MIME wie BookingFile)** verbleiben am Upload-Pfad (wie `FileRepository` bei Buchungen) — in A6 kann bei Bedarf ein `UploadOfferFileRequest` ergänzt werden.
### A6 — Lessons Learned (2026-04-24)
1. **Routen im bestehenden `admin`+`2fa`-Block** (nicht `auth.2fa` der Spec — im Projekt heißt es `2fa` + `AdminMiddleware`). Permission-Gates mit `auth.permission:…` in drei Gruppen: `offers-r` (lesen + DataTable + `GET offer/action` + PDF-Stub), `offers-w` (POST detail, Löschen, File-Upload/Delete), `offer-templates-w` (Vorlagen-CRUD).
2. **5 neue Keys in `config/permissions.php`:** `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w`. Bestehende User haben die Keys **nicht automatisch** in `users.permissions` — nach Deploy im User-Rechte-UI setzen (SuperAdmin) oder JSON per SQL ergänzen; bei `setPermissionsDefault()` greifen sie nur, wenn `permissions` leer/ungültig ist.
3. **Fein-Permissions in `OfferController@action`:** pro `action` werden `offers-w` (Mutation), `offers-send` (send/resend), `offers-accept` (Zusage/Absage/withdraw) geprüft; unbekannte Aktionen verlangen `offers-w`.
4. **Menü** `layout-sidenav` zwischen „Anfragen“ und „Kunden“: sichtbar bei `offers-r` **oder** `offers-w` (nur-Writer-Edge-Case), Icon `ion-md-document`, aktive Pfade `offers` / `offer/*`.
5. **Stub-UI:** `resources/views/offer/index.blade.php` mit server-side DataTable (`data_table_offers`); `detail` + Vorlagen-Views + PDF sind Platzhalter für B1/B3/B5/C1.
6. **Disk `offer`** war bereits in `config/filesystems.php``OfferFileController` setzt `disk=offer` wie beim Booking-Upload.
### A7 — Lessons Learned (2026-04-24)
1. **`ContactFactory` ergänzt:** `Offer` verlangt `contact_id`; ohne Factory müsste jeder Test-Kontakt manuell angelegt werden. `Contact` hatte bereits `HasFactory`, es fehlte nur die Klassendatei unter `database/factories/`.
2. **`OfferFactory` + V1 ohne Rekursion:** Erste `OfferVersion` wird in `OfferFactory::afterCreating()` per `OfferVersion::query()->create()` angelegt und `current_version_id` gesetzt — kein `OfferVersion::factory()` mit verschachteltem `Offer::factory()` (unique `(offer_id, version_no)` und zyklische FKs).
3. **`OfferVersionFactory`:** Standalone-`definition()` nutzt `offer_id``Offer::factory()` und **`version_no` = 2**, weil `Offer::factory()` automatisch V1 erzeugt. Für manuelle Tests: `forOffer($offer, $n)` mit frei wählbarer Versionsnummer.
4. **States:** `Offer::factory()->sent()` / `->accepted()` setzen Offer-Status und synchronisieren die **aktuelle** Version (`refresh()->currentVersion`). `OfferVersionFactory` hat zusätzlich `versionSent()` / `versionAccepted()` für reine Versions-Snapshots.
5. **`OfferItemFactory`:** Hilfsmethoden `forCurrentVersionOf(Offer)` und `forVersion(OfferVersion)`; `asDiscount()` für Rabattzeilen. Erste Position nutzt `OfferItem::TYPE_TRAVEL`.
6. **`OfferTemplateSeeder`:** Fünf realistische Vorlagen per `updateOrCreate(['name' => …])`; `created_by` = kleinster existierender `users.id` (ohne `User::factory()` im Seeder, damit lokale/Stage-Seeds keinen Blind-User erzeugen). Leere User-Tabelle → Seeder bricht ab mit Warnung. Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`.
7. **Smoke-Test:** An dieser Umgebung war kein MySQL-Hostname erreichbar (`getaddrinfo for global-mysql`); Syntax-Check `php -l` grün. Lokal/CI mit laufender DB: `Offer::factory()->create()` + `OfferItem::factory()->forCurrentVersionOf($o)`.
---
## 0. Übersicht
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **79 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
@ -375,12 +486,14 @@ Route::group(['middleware' => ['auth.2fa']], function () {
**Abhängigkeiten:** A2
**Dateien (neu):**
- `database/factories/ContactFactory.php` (FK `offers.contact_id`)
- `database/factories/OfferFactory.php`
- `database/factories/OfferVersionFactory.php`
- `database/factories/OfferItemFactory.php`
- `database/factories/OfferTemplateFactory.php`
- `database/seeds/OfferTemplateSeeder.php` (Namespace `Database\Seeders`, vgl. `composer.json` PSR-4 → `database/seeds/`)
Factories decken alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an.
Factories decken zentrale Stati ab (u. a. `Offer::factory()->sent()` / `->accepted()`, plus Version-States in `OfferVersionFactory`). Seeder legt 5 Test-Templates in `offer_templates` an.
---

View file

@ -1,49 +1,58 @@
<div class="card mb-2 border-primary">
<h6 class="card-header bg-primary text-white py-2" data-toggle="collapse" data-target="#collapseBookingLead" aria-expanded="false" aria-controls="collapseBookingLead">
<h6 class="card-header bg-primary text-white py-2" data-toggle="collapse" data-target="#collapseBookingLead"
aria-expanded="false" aria-controls="collapseBookingLead">
<strong style="line-height: 1.6em">Anfrage</strong>
</h6>
<div class="collapse" id="collapseBookingLead">
<div class="card-body row">
@if($booking->lead->count())
@if ($booking->lead)
<div class="form-group col-sm-6">
<label class="form-label" for="lead_id">{{ __('Anfrage ID') }}</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<a class="text-primary" href="{{route('lead_detail', [$booking->lead->id])}}"><i class="fa fa-edit"></i></a></span>
<a class="text-primary" href="{{ route('lead_detail', [$booking->lead->id]) }}"><i
class="fa fa-edit"></i></a></span>
</div>
{{ Form::text('lead[id]', $booking->lead->id, array('placeholder'=>__('Anfrage ID'), 'class'=>'form-control', 'id'=>'lead_id', 'readonly')) }}
{{ Form::text('lead[id]', $booking->lead->id, ['placeholder' => __('Anfrage ID'), 'class' => 'form-control', 'id' => 'lead_id', 'readonly']) }}
</div>
</div>
<div class="form-group col-sm-6 col-md-6">
<label class="form-label" for="lead_status_id">{{ __('Status ändern') }} </label>
{{ Form::select('lead[status_id]', \App\Models\Lead::getStatusArray() , $booking->lead->status_id, array('class'=>'custom-select')) }}
<label class="form-label" for="lead_status_id">{{ __('Status ändern') }} </label>
{{ Form::select('lead[status_id]', \App\Models\Lead::getStatusArray(), $booking->lead->status_id, ['class' => 'custom-select']) }}
</div>
<div class="form-group col-sm-6">
<label class="custom-control custom-checkbox mt-2">
{!! Form::checkbox('lead[is_rebook]', 1, $booking->lead->is_rebook, ['class'=>'custom-control-input', 'readonly']) !!}
<span class="custom-control-label">{{__('Umbuchung abgeschlossen')}}</span>
{!! Form::checkbox('lead[is_rebook]', 1, $booking->lead->is_rebook, [
'class' => 'custom-control-input',
'readonly',
]) !!}
<span class="custom-control-label">{{ __('Umbuchung abgeschlossen') }}</span>
</label>
</div>
<div class="form-group col-sm-6">
<label class="custom-control custom-checkbox mt-2">
{!! Form::checkbox('lead[is_closed]', 1, $booking->lead->is_closed, ['class'=>'custom-control-input', 'readonly']) !!}
<span class="custom-control-label">{{__('Vorgang abgeschlossen')}}</span>
{!! Form::checkbox('lead[is_closed]', 1, $booking->lead->is_closed, [
'class' => 'custom-control-input',
'readonly',
]) !!}
<span class="custom-control-label">{{ __('Vorgang abgeschlossen') }}</span>
</label>
</div>
<div class="col-sm-12">
<div class="text-left mt-2">
<button type="submit" name="action" value="save_lead_status" class="btn btn-sm btn-secondary">Änderungen speichern</button>&nbsp;
<a href="{{route('leads')}}" class="btn btn-sm btn-default">{{ __('zur Übersicht') }}</a>
<button type="submit" name="action" value="save_lead_status"
class="btn btn-sm btn-secondary">Änderungen speichern</button>&nbsp;
<a href="{{ route('leads') }}" class="btn btn-sm btn-default">{{ __('zur Übersicht') }}</a>
</div>
</div>
@endif
</div>
</div>
</div>
</div>

View file

@ -38,15 +38,82 @@
<div class="form-row">
<div class="form-group col-sm-12">
<label class="form-label" for="component">{{ __('Komponente') }}</label>
<select class="selectpicker" data-style="btn-default" name="component">
<select class="selectpicker" data-style="btn-default" name="component" id="component">
{!! \App\Models\SidebarWidget::getComponentsOptions($widget->component) !!}
</select>
</div>
</div>
<div class="form-row">
<div class="form-row" data-config-panel="html" style="{{ $widget->isStructuredConfigComponent() ? 'display:none;' : '' }}">
<div class="form-group col-sm-12">
<label class="form-label" for="html">{{ __('HTML') }}</label>
{{ Form::textarea('html', $widget->html, ['class' => 'form-control']) }}
<small class="form-text text-muted">
Wird nur genutzt, wenn keine feste Komponente ausgewählt ist oder die Komponente eigene HTML-Inhalte erwartet.
</small>
</div>
</div>
<div data-config-panel="homepage-trips" style="{{ $widget->isHomepageTripsComponent() ? '' : 'display:none;' }}">
<div class="alert alert-info">
Diese Auswahl steuert die Startseiten-Bereiche „Aktuell planbare Reisen" und „Beliebte Kulturreisen".
Die Reihenfolge entspricht der Reihenfolge der ausgewählten Einträge.
</div>
<div class="form-row">
<div class="form-group col-sm-12">
<label class="form-label" for="homepage_page_picker">{{ __('Reise hinzufügen') }}</label>
<div class="input-group">
<select class="selectpicker form-control" data-style="btn-default" name="homepage_page_picker" id="homepage_page_picker" data-live-search="true">
<option value="">{{ __('Bitte Reise wählen') }}</option>
{!! \App\Models\SidebarWidget::getHomepageTravelPageOptions($widget->getHomepagePageIds()) !!}
</select>
<span class="input-group-append">
<button class="btn btn-primary" type="button" id="homepage_add_page">{{ __('hinzufügen') }}</button>
</span>
</div>
<small class="form-text text-muted">
Wenn keine Reisen hinzugefügt sind, nutzt die DEV-Startseite die automatische Prioritätslogik.
</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-12">
<label class="form-label">{{ __('Ausgewählte Reisen') }}</label>
<div class="list-group" id="homepage_selected_pages">
@foreach($widget->getHomepageSelectedTravelPageItems() as $selectedPage)
<div class="list-group-item d-flex align-items-center justify-content-between" data-page-id="{{ $selectedPage['id'] }}">
<input type="hidden" name="homepage_page_ids[]" value="{{ $selectedPage['id'] }}">
<span class="mr-3">
<span class="badge badge-secondary mr-2" data-page-number>{{ $loop->iteration }}</span>
{{ $selectedPage['label'] }}
</span>
<span class="d-flex align-items-center">
<label class="custom-control custom-checkbox mb-0 mr-3">
<input type="checkbox" class="custom-control-input" name="homepage_new_page_ids[]" value="{{ $selectedPage['id'] }}" {{ $selectedPage['is_new'] ? 'checked' : '' }}>
<span class="custom-control-label">
<span class="badge badge-warning">{{ __('Neu') }}</span>
</span>
</label>
<span class="btn-group btn-group-sm mr-2" role="group" aria-label="{{ __('Reihenfolge ändern') }}">
<button class="btn btn-outline-secondary" type="button" data-move-page="up">{{ __('hoch') }}</button>
<button class="btn btn-outline-secondary" type="button" data-move-page="down">{{ __('runter') }}</button>
</span>
<button class="btn btn-sm btn-outline-danger" type="button" data-remove-page>{{ __('entfernen') }}</button>
</span>
</div>
@endforeach
</div>
</div>
</div>
</div>
<div data-config-panel="news" style="{{ $widget->isNewsComponent() ? '' : 'display:none;' }}">
<div class="alert alert-info">
Die Reisenews werden weiterhin automatisch aus dem News-Bereich geladen. Hier wird nur gesteuert,
wie viele Einträge in der Sidebar erscheinen.
</div>
<div class="form-row">
<div class="form-group col-sm-3">
<label class="form-label" for="homepage_news_limit">{{ __('Anzahl Reisenews') }}</label>
<input type="number" min="1" max="12" class="form-control" name="homepage_news_limit" id="homepage_news_limit" value="{{ $widget->getHomepageNewsLimit() }}">
</div>
</div>
</div>
<div class="form-row">
@ -67,6 +134,97 @@
{!! Form::close() !!}
<script>
$(function () {
var componentSelect = $('#component');
var panels = $('[data-config-panel]');
var homepageTripComponents = ['homepagePlannableTrips', 'homepagePopularTrips'];
var pagePicker = $('#homepage_page_picker');
var selectedPages = $('#homepage_selected_pages');
function updateConfigPanels() {
var component = componentSelect.val();
panels.hide();
if (homepageTripComponents.indexOf(component) !== -1) {
$('[data-config-panel="homepage-trips"]').show();
} else if (component === 'newsSidebarWidget') {
$('[data-config-panel="news"]').show();
} else {
$('[data-config-panel="html"]').show();
}
$('.selectpicker').selectpicker('refresh');
}
function addSelectedPage(pageId, label, isNew) {
if (!pageId || selectedPages.find('[data-page-id="' + pageId + '"]').length) {
return;
}
var item = $('<div class="list-group-item d-flex align-items-center justify-content-between"></div>');
var title = $('<span class="mr-3"></span>');
var controls = $('<span class="d-flex align-items-center"></span>');
var checkboxId = 'homepage_new_page_' + pageId;
var newLabel = $('<label class="custom-control custom-checkbox mb-0 mr-3"></label>');
var newCheckbox = $('<input type="checkbox" class="custom-control-input" name="homepage_new_page_ids[]">')
.attr('id', checkboxId)
.val(pageId);
var moveButtons = $('<span class="btn-group btn-group-sm mr-2" role="group" aria-label="{{ __('Reihenfolge ändern') }}"></span>');
var removeButton = $('<button class="btn btn-sm btn-outline-danger" type="button" data-remove-page>{{ __('entfernen') }}</button>');
if (isNew) {
newCheckbox.prop('checked', true);
}
item.attr('data-page-id', pageId);
item.append($('<input type="hidden" name="homepage_page_ids[]">').val(pageId));
title.append($('<span class="badge badge-secondary mr-2" data-page-number></span>'));
title.append(document.createTextNode(label));
newLabel.append(newCheckbox);
newLabel.append($('<span class="custom-control-label"><span class="badge badge-warning">{{ __('Neu') }}</span></span>'));
controls.append(newLabel);
moveButtons.append($('<button class="btn btn-outline-secondary" type="button" data-move-page="up">{{ __('hoch') }}</button>'));
moveButtons.append($('<button class="btn btn-outline-secondary" type="button" data-move-page="down">{{ __('runter') }}</button>'));
controls.append(moveButtons);
controls.append(removeButton);
item.append(title);
item.append(controls);
selectedPages.append(item);
updateSelectedPageNumbers();
}
function updateSelectedPageNumbers() {
selectedPages.find('[data-page-id]').each(function (index) {
$(this).find('[data-page-number]').text(index + 1);
});
}
componentSelect.on('changed.bs.select change', updateConfigPanels);
$('#homepage_add_page').on('click', function () {
var selectedOption = pagePicker.find('option:selected');
addSelectedPage(selectedOption.val(), selectedOption.data('label') || selectedOption.text(), false);
});
selectedPages.on('click', '[data-remove-page]', function () {
$(this).closest('[data-page-id]').remove();
updateSelectedPageNumbers();
});
selectedPages.on('click', '[data-move-page]', function () {
var item = $(this).closest('[data-page-id]');
if ($(this).data('move-page') === 'up') {
item.prev('[data-page-id]').before(item);
} else {
item.next('[data-page-id]').after(item);
}
updateSelectedPageNumbers();
});
updateSelectedPageNumbers();
updateConfigPanels();
});
</script>
{{--
<!-- Modal template -->
<div class="modal fade" id="modals-class">

View file

@ -9,14 +9,15 @@
<div class="card">
<div class="card-datatable table-responsive py-2">
<div class="mr-4 mb-2 text-right">
<a href="{{ route('cms_sidebar_detail', ['new']) }}" class="btn btn-sm btn-primary">Neues Sidbar Widget anlegen</a>
<a href="{{ route('cms_sidebar_detail', ['new']) }}" class="btn btn-sm btn-primary">Neues Sidebar-Widget anlegen</a>
</div>
<table class="datatables-feedbacks table table-striped table-bordered">
<thead>
<tr>
<th style="max-width: 60px;">&nbsp;</th>
<th>{{__('Name')}}</th>
<th>{{__('Sichbar')}}</th>
<th>{{__('Sichtbar auf')}}</th>
<th>{{__('Komponente')}}</th>
<th>{{__('Pos.')}}</th>
<th>{{__('sichtbar')}}</th>
<th></th>
@ -34,10 +35,11 @@
<a href="{{ route('cms_sidebar_detail', [$value->id]) }}">{{ $value->name }}</a>
</td>
<td>{{ $value->getShowsAtString() }}</td>
<td>{{ $value->getComponentLabel() }}</td>
<td>
{{ $value->pos }}
</td>
<td data-sort="{{ $value->status }}">
<td data-sort="{{ $value->active }}">
@if($value->active)
<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>
@else
@ -52,7 +54,7 @@
</tbody>
</table>
<div class="mt-4 col">
<a href="{{ route('cms_sidebar_detail', ['new']) }}" class="btn btn-sm btn-primary">Neues Sidbar Widget anlegen</a>
<a href="{{ route('cms_sidebar_detail', ['new']) }}" class="btn btn-sm btn-primary">Neues Sidebar-Widget anlegen</a>
</div>
</div>
<script>

View file

@ -64,7 +64,7 @@
@endif
@if (Auth::user()->isPermission('crm-bo'))
<li
class="sidenav-item{{ Request::is(['requests', 'bookings', 'booking/*', 'leads', 'lead/*', 'customers', 'customer/*', 'contacts', 'contact/*', 'customer_mails', 'customer_mail/*']) ? ' open' : '' }}">
class="sidenav-item{{ Request::is(['requests', 'bookings', 'booking/*', 'leads', 'lead/*', 'offers', 'offer/*', 'offer-templates', 'offer-template/*', 'customers', 'customer/*', 'contacts', 'contact/*', 'customer_mails', 'customer_mail/*']) ? ' open' : '' }}">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-bed"></i>
<div>Buchungen</div>
@ -93,6 +93,14 @@
</a>
</li>
@endif
@if (Auth::user()->isPermission('offers-r') || Auth::user()->isPermission('offers-w'))
<li class="sidenav-item{{ Request::is(['offers', 'offer/*']) ? ' active' : '' }}">
<a href="{{ route('offers') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-document"></i>
<div>Angebote</div>
</a>
</li>
@endif
@if (Auth::user()->isPermission('crm-bo-cu'))
<li
class="sidenav-item{{ Request::is(['customers', 'customer/*']) ? ' active' : '' }}">

View file

@ -0,0 +1,13 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-3 mb-4">
{{ __('Angebot') }} {{ $offer->offer_number }} <small
class="text-muted">#{{ $offer->id }}</small>
</h4>
<div class="card p-3">
<p class="mb-0">Status: <span class="badge badge-secondary">{{ $offer->status }}</span></p>
<p class="text-muted small mb-0 mt-2">Detail-Editor, Tabs und Aktionen folgen in B3. Aktuelle
Version: V{{ $offer->currentVersion ? $offer->currentVersion->version_no : '—' }}.</p>
</div>
@endsection

View file

@ -0,0 +1,57 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-3 mb-4">
{{ __('Angebote') }}
</h4>
<p class="text-muted">Filter &amp; Aktionen erweitert Ticket B1. Die Tabelle nutzt die Route <code>data_table_offers</code>.</p>
<div class="card">
<div class="table-responsive-track" id="datatables-offers-scroll">
<div class="table-responsive-thumb" id="datatables-offers-thumb"></div>
</div>
<div class="card-datatable table-responsive" id="datatables-offers-table">
<table class="datatables-offers table table-striped table-bordered" id="datatables-offers">
<thead>
<tr>
<th style="max-width: 60px;">&nbsp;</th>
<th>ID</th>
<th>Nr.</th>
<th>Kontakt</th>
<th>Status</th>
<th>Erstellt von</th>
<th>Erstellt am</th>
</tr>
</thead>
</table>
</div>
</div>
<script>
$(function () {
var table = $('#datatables-offers').DataTable({
processing: true,
serverSide: true,
ajax: '{!! route('data_table_offers') !!}',
order: [[1, 'desc']],
columns: [
{data: 'action_edit', name: 'action_edit', orderable: false, searchable: false},
{data: 'id', name: 'offers.id'},
{data: 'offer_number', name: 'offers.offer_number'},
{data: 'contact_name', name: 'contact_name', orderable: false, searchable: false},
{data: 'status_badge', name: 'offers.status', orderable: true, searchable: false},
{data: 'created_name', name: 'created_name', orderable: false, searchable: false},
{data: 'created_at_fmt', name: 'offers.created_at', searchable: false}
],
bLengthChange: false,
iDisplayLength: 100,
language: {url: '/js/German.json'},
drawCallback: function () {
if (typeof dataTableScrollTrack === 'function') {
dataTableScrollTrack('#datatables-offers');
}
}
});
});
</script>
@endsection

View file

@ -0,0 +1,5 @@
{{-- Stub für A6 ersetzt durch B2 (modal-new-offer) --}}
<div class="p-2">
<p class="mb-0">Neues Angebot-Modal: Implementierung in Ticket B2
(<code>OfferService::createBlank / createFromInquiry</code>).</p>
</div>

View file

@ -0,0 +1,14 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-3 mb-4">
@if($id === 'new')
{{ __('Neue Vorlage') }}
@else
{{ __('Vorlage') }}: {{ $template->name }}
@endif
</h4>
<div class="card p-3">
<p class="text-muted mb-0">Vorlagen-Editor folgt in C1.</p>
</div>
@endsection

View file

@ -0,0 +1,14 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-3 mb-4">{{ __('Angebotsvorlagen') }}</h4>
<div class="list-group">
@forelse($templates as $t)
<a class="list-group-item list-group-item-action"
href="{{ route('offer_template_detail', ['id' => $t->id]) }}">{{ $t->name }}</a>
@empty
<p class="text-muted p-2 mb-0">Noch keine Vorlagen anlegen in C1.</p>
@endforelse
</div>
<a class="btn btn-primary mt-3" href="{{ route('offer_template_detail', ['id' => 'new']) }}">Neue Vorlage</a>
@endsection

View file

@ -243,6 +243,28 @@ Route::group(['middleware' => ['admin', '2fa']], function () {
Route::post('lead/ajax/requests', 'LeadController@getAjaxRequests')->name('lead_ajax_requests');
Route::get('/lead/delete/{id}/{del?}', 'LeadController@delete')->name('lead_delete');
});
// Buchungen > Angebote (Modul 6)
Route::group(['middleware' => ['auth.permission:offers-r']], function () {
Route::get('data/table/offers', 'OfferController@getOffers')->name('data_table_offers');
Route::get('/offers/{step?}', 'OfferController@index')->name('offers');
Route::get('/offer/detail/{id}', 'OfferController@detail')->name('offer_detail');
Route::post('/offer/modal/load', 'OfferController@loadModal')->name('offer_modal_load');
Route::get('/offer/pdf/{versionId}/{do?}', 'OfferController@pdf')->name('offer_pdf');
Route::get('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action');
Route::post('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action_store');
});
Route::group(['middleware' => ['auth.permission:offers-w']], function () {
Route::post('/offer/detail/{id}', 'OfferController@store')->name('offer_detail_store');
Route::get('/offer/delete/{id}/{del?}', 'OfferController@delete')->name('offer_delete');
Route::post('/offer/upload/{versionId}', 'OfferFileController@upload')->name('offer_file_upload');
Route::get('/offer/file/delete/{id}', 'OfferFileController@delete')->name('offer_file_delete');
});
Route::group(['middleware' => ['auth.permission:offer-templates-w']], function () {
Route::get('/offer-templates', 'OfferTemplateController@index')->name('offer_templates');
Route::get('/offer-template/detail/{id}', 'OfferTemplateController@detail')->name('offer_template_detail');
Route::post('/offer-template/detail/{id?}', 'OfferTemplateController@store')->name('offer_template_detail_store');
Route::get('/offer-template/delete/{id}', 'OfferTemplateController@delete')->name('offer_template_delete');
});
Route::group(['middleware' => ['auth.permission:crm-bo-cu']], function () {
//Buchungen > Kunden (alt — bleibt erhalten)
Route::get('data/table/customers', 'CustomerController@getCustomers')->name('data_table_customers');

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100001_create_offers_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100002_create_offer_versions_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100003_create_offer_items_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100004_create_offer_templates_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100005_create_offer_files_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100006_create_offer_access_tokens_table.php

View file

@ -0,0 +1 @@
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php