Neustrukturierung Customer / Lead / Booking Phase 2

This commit is contained in:
Kevin Adametz 2026-05-28 17:10:37 +02:00
parent 313f0dbf4e
commit 6df9c401af
69 changed files with 3809 additions and 374 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

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

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