Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
280
app/Console/Commands/BookingsRepairFromTravelBookings.php
Normal file
280
app/Console/Commands/BookingsRepairFromTravelBookings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
172
app/Console/Commands/BookingsResyncFromTravelBookings.php
Normal file
172
app/Console/Commands/BookingsResyncFromTravelBookings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
app/Events/Offer/OfferAccepted.php
Normal file
27
app/Events/Offer/OfferAccepted.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
app/Events/Offer/OfferDeclined.php
Normal file
24
app/Events/Offer/OfferDeclined.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
31
app/Events/Offer/OfferSent.php
Normal file
31
app/Events/Offer/OfferSent.php
Normal 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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
22
app/Events/Offer/OfferWithdrawn.php
Normal file
22
app/Events/Offer/OfferWithdrawn.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
36
app/Exceptions/Offer/InvalidOfferStatusException.php
Normal file
36
app/Exceptions/Offer/InvalidOfferStatusException.php
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
31
app/Exceptions/Offer/OfferNotEditableException.php
Normal file
31
app/Exceptions/Offer/OfferNotEditableException.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
184
app/Http/Controllers/OfferController.php
Normal file
184
app/Http/Controllers/OfferController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/OfferFileController.php
Normal file
63
app/Http/Controllers/OfferFileController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/OfferTemplateController.php
Normal file
60
app/Http/Controllers/OfferTemplateController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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']]));
|
||||
|
||||
|
|
|
|||
104
app/Http/Requests/Offer/OfferTemplateRequest.php
Normal file
104
app/Http/Requests/Offer/OfferTemplateRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Offer/SendOfferRequest.php
Normal file
34
app/Http/Requests/Offer/SendOfferRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/Offer/StoreOfferRequest.php
Normal file
60
app/Http/Requests/Offer/StoreOfferRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Http/Requests/Offer/UpdateVersionRequest.php
Normal file
107
app/Http/Requests/Offer/UpdateVersionRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),[
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
100
app/Repositories/OfferFileRepository.php
Normal file
100
app/Repositories/OfferFileRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/Repositories/OfferRepository.php
Normal file
129
app/Repositories/OfferRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
104
app/Repositories/OfferTemplateRepository.php
Normal file
104
app/Repositories/OfferTemplateRepository.php
Normal 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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
207
app/Repositories/OfferVersionRepository.php
Normal file
207
app/Repositories/OfferVersionRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,10 @@ class TravelUserBookingFewoRepository extends BaseRepository
|
|||
{
|
||||
|
||||
$model = TravelUserBookingFewo::findOrFail($id);
|
||||
if (!$model->invoice_number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$travel_info_user_text = str_replace("€", "€", $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');
|
||||
|
|
|
|||
385
app/Services/OfferService.php
Normal file
385
app/Services/OfferService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
app/Services/ReportDateFilterService.php
Normal file
48
app/Services/ReportDateFilterService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue