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 Request;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
use App\Services\ReportDateFilterService;
|
||||||
use App\Models\Booking;
|
use App\Models\Booking;
|
||||||
use App\Models\TravelCompany;
|
use App\Models\TravelCompany;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
@ -63,22 +63,22 @@ class ReportBookingController extends Controller
|
||||||
$query->whereIn("travel_company_id", Request::get('filter_travel_company_id'));
|
$query->whereIn("travel_company_id", Request::get('filter_travel_company_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_travel_date_from') != ""){
|
$travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report booking travel date from');
|
||||||
$travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d");
|
if($travel_date_from){
|
||||||
$query->where("start_date", '>=', $travel_date_from);
|
$query->where("start_date", '>=', $travel_date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_to') != ""){
|
$travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report booking travel date to');
|
||||||
$travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d");
|
if($travel_date_to){
|
||||||
$query->where("start_date", '<=', $travel_date_to);
|
$query->where("start_date", '<=', $travel_date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_booking_date_from') != ""){
|
$filter_booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report booking booking date from');
|
||||||
$filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d");
|
if($filter_booking_date_from){
|
||||||
$query->where("booking_date", '>=', $filter_booking_date_from);
|
$query->where("booking_date", '>=', $filter_booking_date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_booking_date_to') != ""){
|
$filter_booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report booking booking date to');
|
||||||
$filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d");
|
if($filter_booking_date_to){
|
||||||
$query->where("booking_date", '<=', $filter_booking_date_from);
|
$query->where("booking_date", '<=', $filter_booking_date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ use Request;
|
||||||
use Response;
|
use Response;
|
||||||
|
|
||||||
use HTMLHelper;
|
use HTMLHelper;
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
use App\Services\ReportDateFilterService;
|
||||||
use App\Models\Booking;
|
use App\Models\Booking;
|
||||||
use App\Models\TravelAgenda;
|
use App\Models\TravelAgenda;
|
||||||
use App\Models\TravelCompany;
|
use App\Models\TravelCompany;
|
||||||
|
|
@ -80,22 +80,22 @@ class ReportController extends Controller
|
||||||
$query->whereIn("travel_company_id", Request::get('filter_travel_company_id'));
|
$query->whereIn("travel_company_id", Request::get('filter_travel_company_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_travel_date_from') != ""){
|
$travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report travel date from');
|
||||||
$travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d");
|
if($travel_date_from){
|
||||||
$query->where("start_date", '>=', $travel_date_from);
|
$query->where("start_date", '>=', $travel_date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_to') != ""){
|
$travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report travel date to');
|
||||||
$travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d");
|
if($travel_date_to){
|
||||||
$query->where("start_date", '<=', $travel_date_to);
|
$query->where("start_date", '<=', $travel_date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_booking_date_from') != ""){
|
$booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report booking date from');
|
||||||
$travel_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d");
|
if($booking_date_from){
|
||||||
$query->where("booking_date", '>=', $travel_date_from);
|
$query->where("booking_date", '>=', $booking_date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_booking_date_to') != ""){
|
$booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report booking date to');
|
||||||
$travel_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d");
|
if($booking_date_to){
|
||||||
$query->where("booking_date", '<=', $travel_date_to);
|
$query->where("booking_date", '<=', $booking_date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
|
|
@ -371,15 +371,15 @@ class ReportController extends Controller
|
||||||
if(Request::get('filter_service_provider_id') != ""){
|
if(Request::get('filter_service_provider_id') != ""){
|
||||||
$query->where('service_provider_id', '=', Request::get('filter_service_provider_id'));
|
$query->where('service_provider_id', '=', Request::get('filter_service_provider_id'));
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_from') != ""){
|
$travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report provider travel date from');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($travel_date_from){
|
||||||
$travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($travel_date_from) {
|
||||||
$q->where("start_date", '>=', $travel_date_from);
|
$q->where("start_date", '>=', $travel_date_from);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_to') != ""){
|
$travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report provider travel date to');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($travel_date_to){
|
||||||
$travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($travel_date_to) {
|
||||||
$q->where("start_date", '<=', $travel_date_to);
|
$q->where("start_date", '<=', $travel_date_to);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ use Request;
|
||||||
use Response;
|
use Response;
|
||||||
|
|
||||||
use HTMLHelper;
|
use HTMLHelper;
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
use App\Services\ReportDateFilterService;
|
||||||
use App\Models\Booking;
|
use App\Models\Booking;
|
||||||
use App\Models\FewoLodging;
|
use App\Models\FewoLodging;
|
||||||
use App\Models\TravelAgenda;
|
use App\Models\TravelAgenda;
|
||||||
|
|
@ -52,21 +52,21 @@ class ReportFewoController extends Controller
|
||||||
$query->where('fewo_lodging_id', '=', Request::get('filter_option_fewo_id'));
|
$query->where('fewo_lodging_id', '=', Request::get('filter_option_fewo_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_date_from') != ""){
|
$date_from = ReportDateFilterService::parseDate(Request::get('filter_date_from'), 'report fewo date from');
|
||||||
$date_from = Carbon::parse(Request::get('filter_date_from'))->format("Y-m-d");
|
if($date_from){
|
||||||
$query->where("from_date", '>=', $date_from);
|
$query->where("from_date", '>=', $date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_date_to') != ""){
|
$date_to = ReportDateFilterService::parseDate(Request::get('filter_date_to'), 'report fewo date to');
|
||||||
$date_to = Carbon::parse(Request::get('filter_date_to'))->format("Y-m-d");
|
if($date_to){
|
||||||
$query->where("from_date", '<=', $date_to);
|
$query->where("from_date", '<=', $date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_booking_date_from') != ""){
|
$booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report fewo booking date from');
|
||||||
$booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d");
|
if($booking_date_from){
|
||||||
$query->where("booking_date", '>=', $booking_date_from);
|
$query->where("booking_date", '>=', $booking_date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_booking_date_to') != ""){
|
$booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report fewo booking date to');
|
||||||
$booking_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d");
|
if($booking_date_to){
|
||||||
$query->where("booking_date", '<=', $booking_date_to);
|
$query->where("booking_date", '<=', $booking_date_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Carbon\Carbon;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
use App\Services\ReportDateFilterService;
|
||||||
use App\Models\Booking;
|
use App\Models\Booking;
|
||||||
use App\Models\LeadMail;
|
use App\Models\LeadMail;
|
||||||
use App\Models\FewoLodging;
|
use App\Models\FewoLodging;
|
||||||
|
|
@ -57,12 +58,12 @@ class ReportLeadsController extends Controller
|
||||||
$query->where('...?', '=', Request::get('filter_option_leads_id'));
|
$query->where('...?', '=', Request::get('filter_option_leads_id'));
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
if(Request::get('filter_date_from') != ""){
|
$date_from = ReportDateFilterService::parseDate(Request::get('filter_date_from'), 'report leads date from');
|
||||||
$date_from = Carbon::parse(Request::get('filter_date_from'))->format("Y-m-d");
|
if($date_from){
|
||||||
$query->where("request_date", '>=', $date_from);
|
$query->where("request_date", '>=', $date_from);
|
||||||
}
|
}
|
||||||
if(Request::get('filter_date_to') != ""){
|
$date_to = ReportDateFilterService::parseDate(Request::get('filter_date_to'), 'report leads date to');
|
||||||
$date_to = Carbon::parse(Request::get('filter_date_to'))->format("Y-m-d");
|
if($date_to){
|
||||||
$query->where("request_date", '<=', $date_to);
|
$query->where("request_date", '<=', $date_to);
|
||||||
}
|
}
|
||||||
return $query;
|
return $query;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use Request;
|
use Request;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
use App\Services\ReportDateFilterService;
|
||||||
use App\Models\TravelCompany;
|
use App\Models\TravelCompany;
|
||||||
use App\Models\ServiceProvider;
|
use App\Models\ServiceProvider;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
@ -63,28 +63,28 @@ class ReportProviderController extends Controller
|
||||||
if(Request::get('filter_service_provider_id') != ""){
|
if(Request::get('filter_service_provider_id') != ""){
|
||||||
$query->where('service_provider_id', '=', Request::get('filter_service_provider_id'));
|
$query->where('service_provider_id', '=', Request::get('filter_service_provider_id'));
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_from') != ""){
|
$travel_date_from = ReportDateFilterService::parseDate(Request::get('filter_travel_date_from'), 'report provider travel date from');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($travel_date_from){
|
||||||
$travel_date_from = Carbon::parse(Request::get('filter_travel_date_from'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($travel_date_from) {
|
||||||
$q->where("start_date", '>=', $travel_date_from);
|
$q->where("start_date", '>=', $travel_date_from);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if(Request::get('filter_travel_date_to') != ""){
|
$travel_date_to = ReportDateFilterService::parseDate(Request::get('filter_travel_date_to'), 'report provider travel date to');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($travel_date_to){
|
||||||
$travel_date_to = Carbon::parse(Request::get('filter_travel_date_to'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($travel_date_to) {
|
||||||
$q->where("start_date", '<=', $travel_date_to);
|
$q->where("start_date", '<=', $travel_date_to);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('filter_booking_date_from') != ""){
|
$filter_booking_date_from = ReportDateFilterService::parseDate(Request::get('filter_booking_date_from'), 'report provider booking date from');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($filter_booking_date_from){
|
||||||
$filter_booking_date_from = Carbon::parse(Request::get('filter_booking_date_from'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($filter_booking_date_from) {
|
||||||
$q->where("booking_date", '>=', $filter_booking_date_from);
|
$q->where("booking_date", '>=', $filter_booking_date_from);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if(Request::get('filter_booking_date_to') != ""){
|
$filter_booking_date_to = ReportDateFilterService::parseDate(Request::get('filter_booking_date_to'), 'report provider booking date to');
|
||||||
$query->whereHas('booking', function ($q) {
|
if($filter_booking_date_to){
|
||||||
$filter_booking_date_to = Carbon::parse(Request::get('filter_booking_date_to'))->format("Y-m-d");
|
$query->whereHas('booking', function ($q) use ($filter_booking_date_to) {
|
||||||
$q->where("booking_date", '<=', $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']);
|
return back()->withRequest(Request::all())->withErrors($ret['error']);
|
||||||
}
|
}
|
||||||
if($ret['success'] == true){
|
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.'));
|
\Session()->flash('alert-success', __('Anreiseinfo wurde erstellt/gespeichert.'));
|
||||||
return redirect(route('travel_user_booking_fewo_detail', [$ret['id']]));
|
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'),
|
'greetings' => __('Best regards'),
|
||||||
'model' => $this->travel_user_booking_fewo,
|
'model' => $this->travel_user_booking_fewo,
|
||||||
]);
|
]);
|
||||||
$message->attach($file1['path'], [
|
if ($file1['path'] && is_file($file1['path'])) {
|
||||||
'as' => $file1['name'],
|
$message->attach($file1['path'], [
|
||||||
'mime' => $file1['mine'],
|
'as' => $file1['name'],
|
||||||
]);
|
'mime' => $file1['mine'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->files as $file) {
|
foreach ($this->files as $file) {
|
||||||
$message->attach($file->getPath(),[
|
$message->attach($file->getPath(),[
|
||||||
|
|
|
||||||
|
|
@ -13,31 +13,31 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Angebot (Modul 6).
|
* Angebots-Kopfdatensatz (Modul 6 — Offers).
|
||||||
*
|
*
|
||||||
* Ein Offer ist der logische Angebots-Kopf (Angebotsnummer, Status,
|
* Trägt Angebotsnummer + Beziehungen zu Kontakt / Anfrage / Buchung und
|
||||||
* Referenzen). Die Inhalte (Texte, Positionen, PDF) liegen versionsweise
|
* zeigt via `current_version_id` auf die aktuell gültige OfferVersion.
|
||||||
* in {@see OfferVersion}. Nach dem ersten Versand ist jede Änderung
|
* Die eigentlichen Inhalte (Texte, Positionen, Preise, PDF) liegen in
|
||||||
* eine neue Version (Entscheidung 17.1 Entwicklungsplan).
|
* `offer_versions`. Jede Änderung nach dem ersten Versand erzeugt eine
|
||||||
|
* neue Version (siehe OfferVersion::isEditable()).
|
||||||
*
|
*
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $offer_number
|
* @property string $offer_number
|
||||||
* @property int $contact_id
|
* @property int $contact_id
|
||||||
* @property int|null $inquiry_id
|
* @property int|null $inquiry_id
|
||||||
* @property int|null $booking_id
|
* @property int|null $booking_id
|
||||||
* @property string $status
|
* @property string $status draft|sent|accepted|declined|expired|withdrawn
|
||||||
* @property int|null $current_version_id
|
* @property int|null $current_version_id
|
||||||
* @property int $created_by
|
* @property int $created_by
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @property Carbon|null $deleted_at
|
* @property Carbon|null $deleted_at
|
||||||
* @property-read Contact $contact
|
* @property-read Contact $contact
|
||||||
* @property-read Lead|null $inquiry
|
* @property-read Lead|null $inquiry
|
||||||
* @property-read Booking|null $booking
|
* @property-read Booking|null $booking
|
||||||
* @property-read OfferVersion|null $currentVersion
|
|
||||||
* @property-read Collection|OfferVersion[] $versions
|
* @property-read Collection|OfferVersion[] $versions
|
||||||
* @property-read Collection|OfferAccessToken[] $accessTokens
|
* @property-read OfferVersion|null $currentVersion
|
||||||
* @property-read User $creator
|
* @property-read User $createdBy
|
||||||
*/
|
*/
|
||||||
class Offer extends Model
|
class Offer extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -50,15 +50,13 @@ class Offer extends Model
|
||||||
public const STATUS_EXPIRED = 'expired';
|
public const STATUS_EXPIRED = 'expired';
|
||||||
public const STATUS_WITHDRAWN = 'withdrawn';
|
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_DRAFT,
|
||||||
self::STATUS_SENT,
|
self::STATUS_SENT,
|
||||||
self::STATUS_ACCEPTED,
|
|
||||||
self::STATUS_DECLINED,
|
|
||||||
self::STATUS_EXPIRED,
|
|
||||||
self::STATUS_WITHDRAWN,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $connection = 'mysql';
|
||||||
protected $table = 'offers';
|
protected $table = 'offers';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -79,20 +77,30 @@ class Offer extends Model
|
||||||
'created_by' => 'int',
|
'created_by' => 'int',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Beziehungen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function contact(): BelongsTo
|
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
|
public function inquiry(): BelongsTo
|
||||||
{
|
{
|
||||||
// Nach Modul 3 Phase 2: `Lead`-Model bildet die `inquiries`-Tabelle ab
|
|
||||||
return $this->belongsTo(Lead::class, 'inquiry_id');
|
return $this->belongsTo(Lead::class, 'inquiry_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function booking(): BelongsTo
|
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
|
public function currentVersion(): BelongsTo
|
||||||
|
|
@ -100,33 +108,41 @@ class Offer extends Model
|
||||||
return $this->belongsTo(OfferVersion::class, 'current_version_id');
|
return $this->belongsTo(OfferVersion::class, 'current_version_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function versions(): HasMany
|
public function createdBy(): BelongsTo
|
||||||
{
|
|
||||||
return $this->hasMany(OfferVersion::class)->orderBy('version_no');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function accessTokens(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(OfferAccessToken::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function creator(): BelongsTo
|
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
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
|
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;
|
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-
|
* In der Datenbank liegt nur der **SHA-256-Hash** des Tokens
|
||||||
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
|
* (`token_hash`), nicht der Klartext — das minimiert das Risiko, wenn
|
||||||
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
|
* die DB einmal abgegriffen wird. Der Klartext-Token wird ausschließlich
|
||||||
* per Mail-Link ausgeliefert.
|
* 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
|
* @property int $id
|
||||||
* neue Version versendet, setzt der OfferService den Vorgänger auf
|
* @property int $offer_id
|
||||||
* `revoked_at`.
|
* @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 $expires_at
|
||||||
* @property Carbon|null $first_opened_at
|
* @property Carbon|null $first_opened_at
|
||||||
* @property Carbon|null $revoked_at
|
* @property Carbon|null $revoked_at
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @property-read Offer $offer
|
* @property-read Offer $offer
|
||||||
* @property-read OfferVersion $version
|
* @property-read OfferVersion $offerVersion
|
||||||
|
*
|
||||||
|
* Transient (nicht persistent, nur direkt nach generate()):
|
||||||
|
* @property-read string|null $plain_token
|
||||||
*/
|
*/
|
||||||
class OfferAccessToken extends Model
|
class OfferAccessToken extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
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 $table = 'offer_access_tokens';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -56,65 +62,85 @@ class OfferAccessToken extends Model
|
||||||
'revoked_at' => 'datetime',
|
'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
|
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');
|
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) {
|
->where(function (Builder $q) {
|
||||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Factories / Helper ───────────────────────────────────────────────────
|
||||||
* Erzeugt ein neues Token für die angegebene Version und liefert
|
|
||||||
* den Klartext-Token zurück (nur einmalig abrufbar). In der
|
|
||||||
* Datenbank wird nur der Hash persistiert.
|
|
||||||
*/
|
|
||||||
public static function generate(
|
|
||||||
Offer $offer,
|
|
||||||
OfferVersion $version,
|
|
||||||
?Carbon $expiresAt = null
|
|
||||||
): array {
|
|
||||||
$plain = Str::random(48);
|
|
||||||
$hash = hash('sha256', $plain);
|
|
||||||
|
|
||||||
/** @var self $token */
|
/**
|
||||||
$token = self::create([
|
* Erzeugt einen neuen Access-Token für eine Version. Der Klartext-Token
|
||||||
'offer_id' => $offer->id,
|
* 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,
|
'offer_version_id' => $version->id,
|
||||||
'token_hash' => $hash,
|
'token_hash' => hash('sha256', $plain),
|
||||||
'expires_at' => $expiresAt,
|
'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) {
|
if ($this->revoked_at === null) {
|
||||||
return false;
|
$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;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datei-Anhang einer Angebotsversion (Modul 6).
|
* Datei-Anhang einer Angebotsversion.
|
||||||
*
|
*
|
||||||
* Struktur ist bewusst an {@see BookingFile} angelehnt (identifier,
|
* Bewusst API-kompatibel mit {@see BookingFile} gehalten (gleiche
|
||||||
* filename, dir, original_name, ext, mine, size), damit der vorhandene
|
* Method-Signaturen für `getURL()`, `getIconExt()`, `formatBytes()`,
|
||||||
* `FileRepository::store()` 1:1 wiederverwendet werden kann. `mine`
|
* `getPath()`), damit Blade-Partials wie `booking/modal-new-booking-files`
|
||||||
* bleibt so geschrieben (statt `mime`) zur Konsistenz mit der
|
* und die Dropzone-JS-Helfer direkt wiederverwendet werden können.
|
||||||
* booking_files-Konvention.
|
|
||||||
*
|
*
|
||||||
* @property int $id
|
* Speichert in disk `offer` (Konvention: `storage/app/offer/YYYY/MM/…`).
|
||||||
* @property int $offer_version_id
|
* 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|null $identifier
|
||||||
* @property string $filename
|
* @property string $filename
|
||||||
* @property string $dir
|
* @property string $dir
|
||||||
* @property string $original_name
|
* @property string $original_name
|
||||||
* @property string $ext
|
* @property string $ext
|
||||||
* @property string $mine
|
* @property string $mine
|
||||||
* @property int $size
|
* @property int $size
|
||||||
* @property bool $include_in_pdf
|
* @property bool $include_in_pdf
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @property-read OfferVersion $version
|
* @property-read OfferVersion $offerVersion
|
||||||
*/
|
*/
|
||||||
class OfferFile extends Model
|
class OfferFile extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $connection = 'mysql';
|
||||||
protected $table = 'offer_files';
|
protected $table = 'offer_files';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -54,7 +58,11 @@ class OfferFile extends Model
|
||||||
'include_in_pdf' => 'bool',
|
'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',
|
'default' => 'fa fa-file',
|
||||||
'pdf' => 'fa fa-file-pdf',
|
'pdf' => 'fa fa-file-pdf',
|
||||||
'jpg' => 'fa fa-file-image',
|
'jpg' => 'fa fa-file-image',
|
||||||
|
|
@ -64,36 +72,45 @@ class OfferFile extends Model
|
||||||
'docx' => 'fa fa-file-word',
|
'docx' => 'fa fa-file-word',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function version(): BelongsTo
|
public function offerVersion(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIconExt(): string
|
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
|
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);
|
return \Storage::disk('offer')->path($this->dir . $this->filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function formatBytes(int $precision = 2): string
|
public function formatBytes(int $precision = 2): string
|
||||||
{
|
{
|
||||||
$size = $this->size;
|
$size = (int) $this->size;
|
||||||
if ($size <= 0) {
|
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'];
|
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
|
||||||
|
return round(pow(1024, $base - floor($base)), $precision) . $suffixes[(int) floor($base)];
|
||||||
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,31 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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
|
* `travel_program_id` und `fewo_lodging_id` bleiben aktuell OHNE
|
||||||
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
|
* Foreign-Key, solange die v2-Reiseverwaltung noch nicht nach Laravel
|
||||||
* `metadata` enthält einen Snapshot der Referenzdaten (Titel, Preis,
|
* migriert ist (siehe Risiko R4 im Entwicklungsplan). Ein Snapshot
|
||||||
* Leistungen) — so bleiben Positionen lesbar, auch wenn das Original
|
* relevanter Quelldaten wird zusätzlich in `metadata` gespeichert,
|
||||||
* später gelöscht / migriert / umbenannt wird.
|
* damit Angebote auch nach einem v2-Schema-Wechsel lesbar bleiben.
|
||||||
*
|
*
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property int $offer_version_id
|
* @property int $offer_version_id
|
||||||
* @property int $position
|
* @property int $position
|
||||||
* @property string $type
|
* @property string $type travel|service|option|discount|insurance|custom
|
||||||
* @property string $title
|
* @property string $title
|
||||||
* @property string|null $description
|
* @property string|null $description
|
||||||
* @property int $quantity
|
* @property int $quantity
|
||||||
* @property float $price_per_unit
|
* @property float $price_per_unit
|
||||||
* @property float $total_price
|
* @property float $total_price
|
||||||
* @property int|null $travel_program_id
|
* @property int|null $travel_program_id
|
||||||
* @property int|null $fewo_lodging_id
|
* @property int|null $fewo_lodging_id
|
||||||
* @property array|null $metadata
|
* @property array|null $metadata
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @property-read OfferVersion $version
|
* @property-read OfferVersion $offerVersion
|
||||||
*/
|
*/
|
||||||
class OfferItem extends Model
|
class OfferItem extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -43,6 +45,16 @@ class OfferItem extends Model
|
||||||
public const TYPE_INSURANCE = 'insurance';
|
public const TYPE_INSURANCE = 'insurance';
|
||||||
public const TYPE_CUSTOM = 'custom';
|
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 $table = 'offer_items';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -63,24 +75,25 @@ class OfferItem extends Model
|
||||||
'offer_version_id' => 'int',
|
'offer_version_id' => 'int',
|
||||||
'position' => 'int',
|
'position' => 'int',
|
||||||
'quantity' => 'int',
|
'quantity' => 'int',
|
||||||
'price_per_unit' => 'decimal:2',
|
|
||||||
'total_price' => 'decimal:2',
|
|
||||||
'travel_program_id' => 'int',
|
'travel_program_id' => 'int',
|
||||||
'fewo_lodging_id' => 'int',
|
'fewo_lodging_id' => 'int',
|
||||||
|
'price_per_unit' => 'decimal:2',
|
||||||
|
'total_price' => 'decimal:2',
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function version(): BelongsTo
|
public function offerVersion(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aus Menge × Einzelpreis den Positions-Gesamtpreis berechnen
|
* Berechnet `total_price` aus `quantity * price_per_unit` — wird vom
|
||||||
* (Rabatte negativ — gehört in den Service-Layer zur Summierung).
|
* 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;
|
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.
|
* @property int $id
|
||||||
* `default_items` ist ein JSON-Array von Positionen im Schema
|
* @property int|null $branch_id
|
||||||
* [{title, description, type, price_per_unit, quantity}, …].
|
* @property string $name
|
||||||
*
|
* @property string|null $description
|
||||||
* @property int $id
|
* @property string|null $default_headline
|
||||||
* @property int|null $branch_id
|
* @property string|null $default_intro
|
||||||
* @property string $name
|
* @property string|null $default_itinerary
|
||||||
* @property string|null $description
|
* @property string|null $default_closing
|
||||||
* @property string|null $default_headline
|
* @property array|null $default_items
|
||||||
* @property string|null $default_intro
|
* @property bool $is_active
|
||||||
* @property string|null $default_itinerary
|
* @property int $created_by
|
||||||
* @property string|null $default_closing
|
* @property Carbon $created_at
|
||||||
* @property array|null $default_items
|
* @property Carbon $updated_at
|
||||||
* @property bool $is_active
|
* @property Carbon|null $deleted_at
|
||||||
* @property int $created_by
|
|
||||||
* @property Carbon $created_at
|
|
||||||
* @property Carbon $updated_at
|
|
||||||
* @property Carbon|null $deleted_at
|
|
||||||
* @property-read Branch|null $branch
|
* @property-read Branch|null $branch
|
||||||
* @property-read User $creator
|
* @property-read User $createdBy
|
||||||
*/
|
*/
|
||||||
class OfferTemplate extends Model
|
class OfferTemplate extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $connection = 'mysql';
|
||||||
protected $table = 'offer_templates';
|
protected $table = 'offer_templates';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -55,23 +55,33 @@ class OfferTemplate extends Model
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'branch_id' => 'int',
|
'branch_id' => 'int',
|
||||||
'default_items' => 'array',
|
|
||||||
'is_active' => 'bool',
|
|
||||||
'created_by' => 'int',
|
'created_by' => 'int',
|
||||||
|
'is_active' => 'bool',
|
||||||
|
'default_items' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function branch(): BelongsTo
|
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');
|
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;
|
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
|
* Alle inhaltlichen Felder (Texte, Preise, Gültigkeit, PDF-Pfad, Status,
|
||||||
* und PDF bleiben damit unveränderlich, sobald ein Kunde sie per
|
* Anhänge) hängen hier — nicht am Offer-Kopf. So bleiben bereits versendete
|
||||||
* Freigabe-Link einsehen kann. Neue Änderungen nach dem Versand
|
* Versionen unveränderlich (`isEditable() === false`), während eine neue
|
||||||
* erzeugen eine neue Version (version_no = max+1, status = draft).
|
* Version ihre eigenen Texte/Positionen/Anhänge führt.
|
||||||
*
|
*
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property int $offer_id
|
* @property int $offer_id
|
||||||
* @property int $version_no
|
* @property int $version_no
|
||||||
* @property string $status
|
* @property string $status draft|sent|accepted|declined|expired|superseded
|
||||||
* @property Carbon|null $valid_until
|
* @property Carbon|null $valid_until
|
||||||
* @property float $total_price
|
* @property float $total_price
|
||||||
* @property string|null $headline
|
* @property string|null $headline
|
||||||
* @property string|null $intro_text
|
* @property string|null $intro_text
|
||||||
* @property string|null $itinerary_text
|
* @property string|null $itinerary_text
|
||||||
* @property string|null $closing_text
|
* @property string|null $closing_text
|
||||||
* @property int|null $template_id
|
* @property int|null $template_id
|
||||||
* @property string|null $pdf_path
|
* @property string|null $pdf_path
|
||||||
* @property bool $pdf_archived
|
* @property bool $pdf_archived
|
||||||
* @property Carbon|null $sent_at
|
* @property Carbon|null $sent_at
|
||||||
* @property Carbon|null $accepted_at
|
* @property Carbon|null $accepted_at
|
||||||
* @property string|null $accepted_via
|
* @property string|null $accepted_via customer_link|admin|email
|
||||||
* @property array|null $template_document_ids
|
* @property array|null $template_document_ids JSON-Array von zentral hinterlegten Dokument-IDs
|
||||||
* @property int $created_by
|
* @property int $created_by
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @property-read Offer $offer
|
* @property-read Offer $offer
|
||||||
* @property-read OfferTemplate|null $template
|
* @property-read Collection|OfferItem[] $items
|
||||||
* @property-read Collection|OfferItem[] $items
|
* @property-read Collection|OfferFile[] $files
|
||||||
* @property-read Collection|OfferFile[] $files
|
* @property-read Collection|OfferAccessToken[] $tokens
|
||||||
* @property-read User $creator
|
* @property-read OfferTemplate|null $template
|
||||||
|
* @property-read User $createdBy
|
||||||
*/
|
*/
|
||||||
class OfferVersion extends Model
|
class OfferVersion extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -57,8 +58,9 @@ class OfferVersion extends Model
|
||||||
|
|
||||||
public const ACCEPTED_VIA_LINK = 'customer_link';
|
public const ACCEPTED_VIA_LINK = 'customer_link';
|
||||||
public const ACCEPTED_VIA_ADMIN = 'admin';
|
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 $table = 'offer_versions';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -84,19 +86,36 @@ class OfferVersion extends Model
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'offer_id' => 'int',
|
'offer_id' => 'int',
|
||||||
'version_no' => 'int',
|
'version_no' => 'int',
|
||||||
'valid_until' => 'date',
|
|
||||||
'total_price' => 'decimal:2',
|
|
||||||
'template_id' => 'int',
|
'template_id' => 'int',
|
||||||
'pdf_archived' => 'bool',
|
'created_by' => 'int',
|
||||||
|
'total_price' => 'decimal:2',
|
||||||
|
'valid_until' => 'date',
|
||||||
'sent_at' => 'datetime',
|
'sent_at' => 'datetime',
|
||||||
'accepted_at' => 'datetime',
|
'accepted_at' => 'datetime',
|
||||||
|
'pdf_archived' => 'bool',
|
||||||
'template_document_ids' => 'array',
|
'template_document_ids' => 'array',
|
||||||
'created_by' => 'int',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Beziehungen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function offer(): BelongsTo
|
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
|
public function template(): BelongsTo
|
||||||
|
|
@ -104,23 +123,52 @@ class OfferVersion extends Model
|
||||||
return $this->belongsTo(OfferTemplate::class, 'template_id');
|
return $this->belongsTo(OfferTemplate::class, 'template_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function items(): HasMany
|
public function createdBy(): BelongsTo
|
||||||
{
|
|
||||||
return $this->hasMany(OfferItem::class)->orderBy('position');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function files(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(OfferFile::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function creator(): BelongsTo
|
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
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
|
public function isEditable(): bool
|
||||||
{
|
{
|
||||||
return $this->status === self::STATUS_DRAFT;
|
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(){
|
public function getInvoicePathFile(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getInvoiceFileName();
|
$filename = $this->getInvoiceFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||||
return Storage::disk('fewo_invoices')->path($dir.$filename);
|
return Storage::disk('fewo_invoices')->path($dir.$filename);
|
||||||
}
|
}
|
||||||
|
|
@ -578,6 +581,9 @@ class TravelUserBookingFewo extends Model
|
||||||
public function getInvoiceUrlFile(){
|
public function getInvoiceUrlFile(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getInvoiceFileName();
|
$filename = $this->getInvoiceFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||||
return Storage::disk('fewo_invoices')->url($dir.$filename);
|
return Storage::disk('fewo_invoices')->url($dir.$filename);
|
||||||
}
|
}
|
||||||
|
|
@ -587,6 +593,9 @@ class TravelUserBookingFewo extends Model
|
||||||
public function getInvoiceLastModified(){
|
public function getInvoiceLastModified(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getInvoiceFileName();
|
$filename = $this->getInvoiceFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||||
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->format("H:i d.m.Y");
|
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(){
|
public function isChangeLowerInvoiceCreate(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getInvoiceFileName();
|
$filename = $this->getInvoiceFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||||
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->gt($this->last_change_at);
|
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(){
|
public function getTravelInfoPathFile(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getTravelInfoFileName();
|
$filename = $this->getTravelInfoFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||||
return Storage::disk('fewo_infos')->path($dir.$filename);
|
return Storage::disk('fewo_infos')->path($dir.$filename);
|
||||||
}
|
}
|
||||||
|
|
@ -654,6 +669,9 @@ class TravelUserBookingFewo extends Model
|
||||||
public function getTravelInfoUrlFile(){
|
public function getTravelInfoUrlFile(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getTravelInfoFileName();
|
$filename = $this->getTravelInfoFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||||
return Storage::disk('fewo_infos')->url($dir.$filename);
|
return Storage::disk('fewo_infos')->url($dir.$filename);
|
||||||
}
|
}
|
||||||
|
|
@ -663,6 +681,9 @@ class TravelUserBookingFewo extends Model
|
||||||
public function getTravelInfoLastModified(){
|
public function getTravelInfoLastModified(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getTravelInfoFileName();
|
$filename = $this->getTravelInfoFileName();
|
||||||
|
if(!$filename){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||||
return Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->format("H:i d.m.Y");
|
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(){
|
public function isChangeLowerTravelInfoCreate(){
|
||||||
$dir = $this->getBookingDateYear()."/";
|
$dir = $this->getBookingDateYear()."/";
|
||||||
$filename = $this->getTravelInfoFileName();
|
$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 Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->gt($this->last_change_at);
|
||||||
}
|
}
|
||||||
return false;
|
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);
|
$model = TravelUserBookingFewo::findOrFail($id);
|
||||||
|
if (!$model->invoice_number) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$travel_info_user_text = str_replace("€", "€", $travel_info_user_text);
|
$travel_info_user_text = str_replace("€", "€", $travel_info_user_text);
|
||||||
$model->info_mail_text = $travel_info_user_text;
|
$model->info_mail_text = $travel_info_user_text;
|
||||||
$model->save();
|
$model->save();
|
||||||
|
|
@ -70,6 +74,9 @@ class TravelUserBookingFewoRepository extends BaseRepository
|
||||||
$path = $model->getTravelInfoPath();
|
$path = $model->getTravelInfoPath();
|
||||||
$dir = $model->getTravelInfoDir();
|
$dir = $model->getTravelInfoDir();
|
||||||
$filename = $model->getTravelInfoFileName();
|
$filename = $model->getTravelInfoFileName();
|
||||||
|
if (!$filename) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$pdf_file = new CreatePDF('pdf.travel_info_fewo', 'fewo_infos');
|
$pdf_file = new CreatePDF('pdf.travel_info_fewo', 'fewo_infos');
|
||||||
$pdf_file->create($data, $filename, 'save', $path);
|
$pdf_file->create($data, $filename, 'save', $path);
|
||||||
$pdf_file->merger($dir, $filename, 'sterntours-template-logo');
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,11 @@ return [
|
||||||
'crm-bo-bo' => ['name' => 'ADMIN CRM > Buchungen > Buchungen', 'color' => 'admin'],
|
'crm-bo-bo' => ['name' => 'ADMIN CRM > Buchungen > Buchungen', 'color' => 'admin'],
|
||||||
'crm-bo-le' => ['name' => 'ADMIN CRM > Buchungen > Anfragen', 'color' => 'admin'],
|
'crm-bo-le' => ['name' => 'ADMIN CRM > Buchungen > Anfragen', 'color' => 'admin'],
|
||||||
'crm-bo-cu' => ['name' => 'ADMIN CRM > Buchungen > Kunden', 'color' => 'admin'],
|
'crm-bo-cu' => ['name' => 'ADMIN CRM > Buchungen > Kunden', 'color' => 'admin'],
|
||||||
|
'offers-r' => ['name' => 'ADMIN CRM > Buchungen > Angebote (lesen)', 'color' => 'admin'],
|
||||||
|
'offers-w' => ['name' => 'ADMIN CRM > Buchungen > Angebote (bearbeiten)', 'color' => 'admin'],
|
||||||
|
'offers-send' => ['name' => 'ADMIN CRM > Buchungen > Angebote (versenden)', 'color' => 'admin'],
|
||||||
|
'offers-accept' => ['name' => 'ADMIN CRM > Buchungen > Angebote (Zusage / Absage / Zurückzug)', 'color' => 'admin'],
|
||||||
|
'offer-templates-w' => ['name' => 'ADMIN CRM > Buchungen > Angebotsvorlagen', 'color' => 'admin'],
|
||||||
'crm-cm' => ['name' => 'ADMIN CRM > Kundenverwaltung', 'color' => 'admin'],
|
'crm-cm' => ['name' => 'ADMIN CRM > Kundenverwaltung', 'color' => 'admin'],
|
||||||
'crm-cm-cf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)', 'color' => 'admin'],
|
'crm-cm-cf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)', 'color' => 'admin'],
|
||||||
'crm-cm-bf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)', 'color' => 'admin'],
|
'crm-cm-bf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)', 'color' => 'admin'],
|
||||||
|
|
|
||||||
25
database/factories/ContactFactory.php
Normal file
25
database/factories/ContactFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact>
|
||||||
|
*/
|
||||||
|
class ContactFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Contact::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->lastName(),
|
||||||
|
'firstname' => fake()->firstName(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'city' => fake()->city(),
|
||||||
|
'phone' => fake()->phoneNumber(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
85
database/factories/OfferFactory.php
Normal file
85
database/factories/OfferFactory.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\Offer;
|
||||||
|
use App\Models\OfferVersion;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Offer>
|
||||||
|
*/
|
||||||
|
class OfferFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Offer::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$y = (int) now()->format('Y');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'offer_number' => $y . '-' . str_pad((string) fake()->unique()->numberBetween(1, 99_999), 5, '0', STR_PAD_LEFT),
|
||||||
|
'contact_id' => Contact::factory(),
|
||||||
|
'inquiry_id' => null,
|
||||||
|
'booking_id' => null,
|
||||||
|
'status' => Offer::STATUS_DRAFT,
|
||||||
|
'current_version_id' => null,
|
||||||
|
'created_by' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(function (Offer $offer) {
|
||||||
|
if ($offer->current_version_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$v = OfferVersion::query()->create([
|
||||||
|
'offer_id' => $offer->id,
|
||||||
|
'version_no' => 1,
|
||||||
|
'status' => OfferVersion::STATUS_DRAFT,
|
||||||
|
'valid_until' => null,
|
||||||
|
'total_price' => 0,
|
||||||
|
'headline' => 'Test-Angebot',
|
||||||
|
'created_by' => $offer->created_by,
|
||||||
|
]);
|
||||||
|
$offer->update(['current_version_id' => $v->id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sent(): static
|
||||||
|
{
|
||||||
|
return $this
|
||||||
|
->state(['status' => Offer::STATUS_SENT])
|
||||||
|
->afterCreating(function (Offer $offer) {
|
||||||
|
$v = $offer->refresh()->currentVersion;
|
||||||
|
if (! $v) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$v->update([
|
||||||
|
'status' => OfferVersion::STATUS_SENT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): static
|
||||||
|
{
|
||||||
|
return $this
|
||||||
|
->state(['status' => Offer::STATUS_ACCEPTED])
|
||||||
|
->afterCreating(function (Offer $offer) {
|
||||||
|
$v = $offer->refresh()->currentVersion;
|
||||||
|
if (! $v) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$v->update([
|
||||||
|
'status' => OfferVersion::STATUS_ACCEPTED,
|
||||||
|
'sent_at' => $v->sent_at ?? now(),
|
||||||
|
'accepted_at' => now(),
|
||||||
|
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
72
database/factories/OfferItemFactory.php
Normal file
72
database/factories/OfferItemFactory.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Offer;
|
||||||
|
use App\Models\OfferItem;
|
||||||
|
use App\Models\OfferVersion;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferItem>
|
||||||
|
*/
|
||||||
|
class OfferItemFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OfferItem::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$qty = fake()->numberBetween(1, 4);
|
||||||
|
$ppu = (float) fake()->randomFloat(2, 200, 4_000);
|
||||||
|
return [
|
||||||
|
'position' => 0,
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => fake()->sentence(3),
|
||||||
|
'description' => null,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'price_per_unit' => $ppu,
|
||||||
|
'total_price' => round($qty * $ppu, 2),
|
||||||
|
'travel_program_id' => null,
|
||||||
|
'fewo_lodging_id' => null,
|
||||||
|
'metadata' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asDiscount(float $absAmount = 150): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'type' => OfferItem::TYPE_DISCOUNT,
|
||||||
|
'title' => 'Rabatt',
|
||||||
|
'quantity' => 1,
|
||||||
|
'price_per_unit' => -1 * abs($absAmount),
|
||||||
|
'total_price' => -1 * abs($absAmount),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt die Position an die aktuelle Version eines per Factory erstellten Angebots.
|
||||||
|
*/
|
||||||
|
public function forCurrentVersionOf(Offer $offer): static
|
||||||
|
{
|
||||||
|
$v = $offer->currentVersion;
|
||||||
|
if (! $v) {
|
||||||
|
throw new \InvalidArgumentException('Offer hat keine currentVersion — zuerst Offer::factory() erzeugen.');
|
||||||
|
}
|
||||||
|
return $this->state(fn (array $a) => [
|
||||||
|
'offer_version_id' => $v->id,
|
||||||
|
'position' => (int) $v->items()->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forVersion(OfferVersion $version, ?int $position = null): static
|
||||||
|
{
|
||||||
|
return $this->state(function (array $a) use ($version, $position) {
|
||||||
|
$pos = $position ?? (int) $version->items()->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'offer_version_id' => $version->id,
|
||||||
|
'position' => $pos,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
database/factories/OfferTemplateFactory.php
Normal file
39
database/factories/OfferTemplateFactory.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OfferItem;
|
||||||
|
use App\Models\OfferTemplate;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferTemplate>
|
||||||
|
*/
|
||||||
|
class OfferTemplateFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OfferTemplate::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'branch_id' => null,
|
||||||
|
'name' => 'Vorlage ' . fake()->words(2, true),
|
||||||
|
'description' => fake()->optional()->sentence(),
|
||||||
|
'default_headline' => fake()->sentence(4),
|
||||||
|
'default_intro' => '<p>' . fake()->paragraph() . '</p>',
|
||||||
|
'default_itinerary' => null,
|
||||||
|
'default_closing' => null,
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => 'Reiseleistung (Standard)',
|
||||||
|
'quantity' => 2,
|
||||||
|
'price_per_unit' => 1_250.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'created_by' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
database/factories/OfferVersionFactory.php
Normal file
70
database/factories/OfferVersionFactory.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Offer;
|
||||||
|
use App\Models\OfferVersion;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferVersion>
|
||||||
|
*/
|
||||||
|
class OfferVersionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OfferVersion::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
// `Offer::factory()` legt V1 in OfferFactory::afterCreating an — V2+ ist
|
||||||
|
// pro (offer_id, version_no) unique.
|
||||||
|
return [
|
||||||
|
'offer_id' => Offer::factory(),
|
||||||
|
'version_no' => 2,
|
||||||
|
'status' => OfferVersion::STATUS_DRAFT,
|
||||||
|
'valid_until' => null,
|
||||||
|
'total_price' => fake()->randomFloat(2, 100, 15_000),
|
||||||
|
'headline' => fake()->sentence(4),
|
||||||
|
'intro_text' => '<p>' . fake()->paragraph() . '</p>',
|
||||||
|
'itinerary_text' => null,
|
||||||
|
'closing_text' => null,
|
||||||
|
'template_id' => null,
|
||||||
|
'pdf_path' => null,
|
||||||
|
'pdf_archived' => false,
|
||||||
|
'sent_at' => null,
|
||||||
|
'accepted_at' => null,
|
||||||
|
'accepted_via' => null,
|
||||||
|
'template_document_ids' => null,
|
||||||
|
'created_by' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOffer(Offer $offer, int $versionNo = 2): static
|
||||||
|
{
|
||||||
|
return $this->state(function (array $a) use ($offer, $versionNo) {
|
||||||
|
return [
|
||||||
|
'offer_id' => $offer->id,
|
||||||
|
'version_no' => $versionNo,
|
||||||
|
'created_by' => $offer->created_by,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function versionSent(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'status' => OfferVersion::STATUS_SENT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function versionAccepted(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'status' => OfferVersion::STATUS_ACCEPTED,
|
||||||
|
'sent_at' => now(),
|
||||||
|
'accepted_at' => now(),
|
||||||
|
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Database\Migrations\Migration;
|
||||||
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
|
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
|
||||||
* @see https://github.com/cviebrock/sequel-pro-laravel-export
|
* @see https://github.com/cviebrock/sequel-pro-laravel-export
|
||||||
*/
|
*/
|
||||||
class CreateBookingVoucherTable extends Migration
|
class CreateBookingVoucherAgencyTable extends Migration
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run the migrations.
|
* Run the migrations.
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
|
|
||||||
* @see https://github.com/cviebrock/sequel-pro-laravel-export
|
|
||||||
*/
|
|
||||||
class CreateBookingVoucherTable extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function up()
|
|
||||||
{
|
|
||||||
Schema::create('booking_voucher', function (Blueprint $table) {
|
|
||||||
$table->bigIncrements('id');
|
|
||||||
$table->bigInteger('booking_id');
|
|
||||||
$table->binary('binary_data');
|
|
||||||
$table->dateTime('created_at');
|
|
||||||
$table->dateTime('updated_at');
|
|
||||||
|
|
||||||
$table->index('booking_id', 'booking_voucher_booking_id_idx');
|
|
||||||
|
|
||||||
$table->foreign('booking_id', 'booking_voucher_booking_id_booking_id')->references('id')->on('booking')->onDelete('CASCADE
|
|
||||||
')->onUpdate('RESTRICT');
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function down()
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('booking_voucher');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,19 +28,14 @@ return new class extends Migration
|
||||||
$t->id();
|
$t->id();
|
||||||
$t->string('offer_number', 32)->unique();
|
$t->string('offer_number', 32)->unique();
|
||||||
|
|
||||||
$t->foreignId('contact_id')
|
// FK-Typen MÜSSEN exakt zur Referenz passen.
|
||||||
->constrained('contacts')
|
// Die Legacy-Tabellen contacts/inquiries/booking sind aus dem
|
||||||
->restrictOnDelete();
|
// alten Sequel-Pro-Export mit `bigint` (SIGNED) angelegt; die
|
||||||
|
// users-Tabelle nutzt `int unsigned`. Daher hier explizit
|
||||||
$t->foreignId('inquiry_id')
|
// typisieren statt foreignId() (das würde bigint UNSIGNED erzeugen).
|
||||||
->nullable()
|
$t->bigInteger('contact_id');
|
||||||
->constrained('inquiries')
|
$t->bigInteger('inquiry_id')->nullable();
|
||||||
->nullOnDelete();
|
$t->bigInteger('booking_id')->nullable();
|
||||||
|
|
||||||
$t->foreignId('booking_id')
|
|
||||||
->nullable()
|
|
||||||
->constrained('booking')
|
|
||||||
->nullOnDelete();
|
|
||||||
|
|
||||||
$t->enum('status', [
|
$t->enum('status', [
|
||||||
'draft',
|
'draft',
|
||||||
|
|
@ -54,11 +49,17 @@ return new class extends Migration
|
||||||
// FK wird in 2026_04_17_100007 nachträglich gesetzt
|
// FK wird in 2026_04_17_100007 nachträglich gesetzt
|
||||||
$t->unsignedBigInteger('current_version_id')->nullable();
|
$t->unsignedBigInteger('current_version_id')->nullable();
|
||||||
|
|
||||||
$t->foreignId('created_by')->constrained('users');
|
// users.id ist int unsigned (Legacy)
|
||||||
|
$t->unsignedInteger('created_by');
|
||||||
|
|
||||||
$t->timestamps();
|
$t->timestamps();
|
||||||
$t->softDeletes();
|
$t->softDeletes();
|
||||||
|
|
||||||
|
$t->foreign('contact_id')->references('id')->on('contacts')->restrictOnDelete();
|
||||||
|
$t->foreign('inquiry_id')->references('id')->on('inquiries')->nullOnDelete();
|
||||||
|
$t->foreign('booking_id')->references('id')->on('booking')->nullOnDelete();
|
||||||
|
$t->foreign('created_by')->references('id')->on('users');
|
||||||
|
|
||||||
$t->index(['status', 'contact_id']);
|
$t->index(['status', 'contact_id']);
|
||||||
$t->index('inquiry_id');
|
$t->index('inquiry_id');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,13 @@ return new class extends Migration
|
||||||
// die mit dieser Version (als Anhang) verknüpft sind
|
// die mit dieser Version (als Anhang) verknüpft sind
|
||||||
$t->json('template_document_ids')->nullable();
|
$t->json('template_document_ids')->nullable();
|
||||||
|
|
||||||
$t->foreignId('created_by')->constrained('users');
|
// users.id ist int unsigned (Legacy) → siehe Migration 100001
|
||||||
|
$t->unsignedInteger('created_by');
|
||||||
|
|
||||||
$t->timestamps();
|
$t->timestamps();
|
||||||
|
|
||||||
|
$t->foreign('created_by')->references('id')->on('users');
|
||||||
|
|
||||||
$t->unique(['offer_id', 'version_no']);
|
$t->unique(['offer_id', 'version_no']);
|
||||||
$t->index(['offer_id', 'status']);
|
$t->index(['offer_id', 'status']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,8 @@ return new class extends Migration
|
||||||
// `branch` existiert schon im CRM — Vorlagen können so pro
|
// `branch` existiert schon im CRM — Vorlagen können so pro
|
||||||
// Filiale gepflegt werden. Eine spätere Erweiterung auf
|
// Filiale gepflegt werden. Eine spätere Erweiterung auf
|
||||||
// `organization_id` (Modul 5) erfolgt additiv.
|
// `organization_id` (Modul 5) erfolgt additiv.
|
||||||
$t->foreignId('branch_id')
|
// FK-Typen: branch.id ist bigint signed, users.id int unsigned.
|
||||||
->nullable()
|
$t->bigInteger('branch_id')->nullable();
|
||||||
->constrained('branch')
|
|
||||||
->nullOnDelete();
|
|
||||||
|
|
||||||
$t->string('name');
|
$t->string('name');
|
||||||
$t->text('description')->nullable();
|
$t->text('description')->nullable();
|
||||||
|
|
@ -43,11 +41,14 @@ return new class extends Migration
|
||||||
$t->json('default_items')->nullable();
|
$t->json('default_items')->nullable();
|
||||||
|
|
||||||
$t->boolean('is_active')->default(true);
|
$t->boolean('is_active')->default(true);
|
||||||
$t->foreignId('created_by')->constrained('users');
|
$t->unsignedInteger('created_by');
|
||||||
|
|
||||||
$t->timestamps();
|
$t->timestamps();
|
||||||
$t->softDeletes();
|
$t->softDeletes();
|
||||||
|
|
||||||
|
$t->foreign('branch_id')->references('id')->on('branch')->nullOnDelete();
|
||||||
|
$t->foreign('created_by')->references('id')->on('users');
|
||||||
|
|
||||||
$t->index(['branch_id', 'is_active']);
|
$t->index(['branch_id', 'is_active']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
115
database/seeds/OfferTemplateSeeder.php
Normal file
115
database/seeds/OfferTemplateSeeder.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\OfferItem;
|
||||||
|
use App\Models\OfferTemplate;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt 5 Angebots-Vorlagen für manuelle/QA-Tests an.
|
||||||
|
*
|
||||||
|
* Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`
|
||||||
|
* (benötigt mindestens einen User in `users` und ausgeführte Offer-Migrationen).
|
||||||
|
*/
|
||||||
|
class OfferTemplateSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$userId = User::query()->orderBy('id')->value('id');
|
||||||
|
if (! $userId) {
|
||||||
|
$this->command?->warn('OfferTemplateSeeder: kein User — übersprungen.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$templates = [
|
||||||
|
[
|
||||||
|
'name' => 'Südafrika – Klassik (14 Tage)',
|
||||||
|
'description' => 'Kombirundreise, Baustein für B2-Modal-Tests',
|
||||||
|
'default_headline' => 'Ihr Südafrika-Erlebnis',
|
||||||
|
'default_intro' => '<p>Sehr geehrte Gäste, wir freuen uns, Ihnen folgendes Reiseangebot zu unterbreiten.</p>',
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => 'Rundreise 14 Tage inkl. Mietwagen',
|
||||||
|
'quantity' => 2,
|
||||||
|
'price_per_unit' => 1_800.00,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_OPTION,
|
||||||
|
'title' => 'Business-Class-Aufpreis (Flug)',
|
||||||
|
'quantity' => 2,
|
||||||
|
'price_per_unit' => 450.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Namibia – Self-Drive',
|
||||||
|
'default_headline' => 'Namibia per Mietwagen',
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => 'Mietwagen 10 Tage',
|
||||||
|
'quantity' => 1,
|
||||||
|
'price_per_unit' => 890.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Mauritius – Strand (8 Nächte)',
|
||||||
|
'default_headline' => 'Honeymoon-Insel',
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => 'Hotel 5* Halbpension (p.P.)',
|
||||||
|
'quantity' => 2,
|
||||||
|
'price_per_unit' => 1_200.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Kurzangebot – Städtereise',
|
||||||
|
'default_headline' => 'Wochenend-Trip',
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_SERVICE,
|
||||||
|
'title' => 'Transfer Flughafen',
|
||||||
|
'quantity' => 1,
|
||||||
|
'price_per_unit' => 75.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Rabatt-Template (Frühbucher)',
|
||||||
|
'default_headline' => 'Frühbucher-Sonderkonditionen',
|
||||||
|
'default_items' => [
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_TRAVEL,
|
||||||
|
'title' => 'Basispaket',
|
||||||
|
'quantity' => 2,
|
||||||
|
'price_per_unit' => 1_000.00,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => OfferItem::TYPE_DISCOUNT,
|
||||||
|
'title' => 'Frühbucher-Rabatt',
|
||||||
|
'quantity' => 1,
|
||||||
|
'price_per_unit' => -200.00,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($templates as $t) {
|
||||||
|
OfferTemplate::query()->updateOrCreate(
|
||||||
|
['name' => $t['name']],
|
||||||
|
array_merge($t, [
|
||||||
|
'branch_id' => null,
|
||||||
|
'is_active' => true,
|
||||||
|
'created_by' => $userId,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# Umsetzung: Neustrukturierung Customer / Lead / Booking
|
# Umsetzung: Neustrukturierung Customer / Lead / Booking
|
||||||
|
|
||||||
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Phase 2 auf **Test** erfolgreich migriert und verifiziert — bereit für Live-Deploy.
|
**Status:** Phase 1 auf **Live** abgeschlossen (2026-04-17); Hotfix gegen Import-Bug ausgespielt (2026-04-18); Phase 2 auf **Test** erfolgreich migriert und verifiziert — Phase-2-Live-Deploy nach Stabilitätsphase.
|
||||||
**Erstellt:** April 2025
|
**Erstellt:** April 2025
|
||||||
**Letzte Aktualisierung:** 2026-04-17
|
**Letzte Aktualisierung:** 2026-04-18
|
||||||
**Konzept:** [konzept.md](konzept.md)
|
**Konzept:** [konzept.md](konzept.md)
|
||||||
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
|
**Deploy-Handbücher:** [phase-1-live-deploy.md](phase-1-live-deploy.md) · [phase-2-live-deploy.md](phase-2-live-deploy.md)
|
||||||
|
**Hotfix-Dokumentation:** [../hotfix-2026-04-18/HOTFIX.md](../hotfix-2026-04-18/HOTFIX.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ✅ Abgeschlossen | ✅ **Ja, Batch 27–29** | ⬜ Nein (Deploy-Handbuch fertig) |
|
| Phase 2 — Tabellen-Migrationen (3 Migrationsdateien) | ✅ Abgeschlossen | ✅ **Ja, Batch 27–29** | ⬜ Nein (Deploy-Handbuch fertig) |
|
||||||
| Phase 2 — Smoke-Test-Fixes 2026-04-17 (`Lead::bookings()` FK; `orderColumn`-SQL in 4 Controllern; Blade-Column-`name:` in `contact/index.blade.php`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt) |
|
| Phase 2 — Smoke-Test-Fixes 2026-04-17 (`Lead::bookings()` FK; `orderColumn`-SQL in 4 Controllern; Blade-Column-`name:` in `contact/index.blade.php`) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt) |
|
||||||
| Phase-1-Hotfix — nullable Parameter (`?int $mailDirId`, `?string $subdir`) in 3 Service-Klassen (Mail-Dialoge mit NULL-Werten) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt, siehe Handbuch) |
|
| Phase-1-Hotfix — nullable Parameter (`?int $mailDirId`, `?string $subdir`) in 3 Service-Klassen (Mail-Dialoge mit NULL-Werten) | ✅ Abgeschlossen | ✅ Ja | ⬜ Nein (in Phase-2-Deploy gebündelt, siehe Handbuch) |
|
||||||
|
| **Hotfix 2026-04-18** — `BookingImport.php` auf Live: `'inquiry_id' → 'lead_id'` (Phase-2-Datei versehentlich beim Phase-1-Deploy gerutscht, neue Bookings mit `lead_id=NULL`); Reparatur-DB-Query + 2 Artisan-Commands (`bookings:resync-from-travel-bookings`, `bookings:repair-from-travel-bookings`) | ✅ Abgeschlossen | ✅ Ja | ✅ **2026-04-18** |
|
||||||
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
| Phase 3 — Participants konsolidieren | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
||||||
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
| Phase 4 — Communications / Notices / Attachments | ⬜ Ausstehend (Migrationen geschrieben, Code noch nicht) | ⬜ Nein | ⬜ Nein |
|
||||||
|
|
||||||
|
|
|
||||||
172
dev/hotfix-2026-04-18/BookingImport.php
Normal file
172
dev/hotfix-2026-04-18/BookingImport.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Arrangement;
|
||||||
|
use App\Models\Participant;
|
||||||
|
use App\Models\TravelBooking;
|
||||||
|
use App\Models\BookingServiceItem;
|
||||||
|
use App\Repositories\DraftRepository;
|
||||||
|
|
||||||
|
class BookingImport
|
||||||
|
{
|
||||||
|
|
||||||
|
public static function importFrom(TravelBooking $travel_booking){
|
||||||
|
|
||||||
|
// ---- createCustomer
|
||||||
|
$data = [
|
||||||
|
'salutation_id' => $travel_booking->salutation_id,
|
||||||
|
'name' => $travel_booking->last_name,
|
||||||
|
'firstname' => $travel_booking->first_name,
|
||||||
|
'street' => $travel_booking->street,
|
||||||
|
'zip' => $travel_booking->zipcode,
|
||||||
|
'city' => $travel_booking->city,
|
||||||
|
'country_id' => $travel_booking->country_id,
|
||||||
|
'phone' => $travel_booking->phone,
|
||||||
|
'phonemobile' => $travel_booking->mobile,
|
||||||
|
'email' => $travel_booking->email
|
||||||
|
];
|
||||||
|
$customer = Customer::create($data);
|
||||||
|
|
||||||
|
// ---- createLead
|
||||||
|
$data = [
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'request_date' => $travel_booking->created,
|
||||||
|
'travelperiod_start' => $travel_booking->selected_start_date,
|
||||||
|
'travelperiod_end' => $travel_booking->selected_end_date,
|
||||||
|
'remarks' => $travel_booking->comments,
|
||||||
|
'sf_guard_user_id' => 15,
|
||||||
|
'is_closed' => true,
|
||||||
|
'initialcontacttype_id' => 14,
|
||||||
|
'status_id' => 7,
|
||||||
|
'website_id' => 1,
|
||||||
|
];
|
||||||
|
$lead = Lead::create($data);
|
||||||
|
$lead->updateNextDueDate();
|
||||||
|
|
||||||
|
$comfort = false;
|
||||||
|
if(isset($travel_booking->drafts['comfort']) && $travel_booking->drafts['comfort']){
|
||||||
|
$comfort = true;
|
||||||
|
}
|
||||||
|
$data = [
|
||||||
|
'booking_date' => $travel_booking->created->format('Y-m-d'),
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'lead_id' => $lead->id,
|
||||||
|
'new_drafts' => $travel_booking->drafts === null ? 0 : 1,
|
||||||
|
'sf_guard_user_id' => 15,
|
||||||
|
'branch_id' => 4,
|
||||||
|
'pax' => $travel_booking->selected_adults,
|
||||||
|
'title' => isset($travel_booking->selected_travel['travel_title']) ? $travel_booking->selected_travel['travel_title'] : "",
|
||||||
|
'comfort' => $comfort,
|
||||||
|
'start_date' => $travel_booking->selected_start_date->format('Y-m-d'),
|
||||||
|
'end_date' => $travel_booking->selected_end_date->format('Y-m-d'),
|
||||||
|
'website_id' => 1,
|
||||||
|
'travel_number' => isset($travel_booking->selected_travel['travel_number']) ? $travel_booking->selected_travel['travel_number'] : null,
|
||||||
|
/*'participant_name' => isset($travel_booking->participants[0]['last_name']) ? $travel_booking->participants[0]['last_name'] : null,
|
||||||
|
'participant_firstname' => isset($travel_booking->participants[0]['first_name']) ? $travel_booking->participants[0]['first_name'] : null,
|
||||||
|
'participant_birthdate' => isset($travel_booking->participants[0]['birthday']) ? date( "Y-m-d", strtotime($travel_booking->participants[0]['birthday'])) : null,
|
||||||
|
'participant_salutation_id' => isset($travel_booking->participants[0]['gender']) ? $travel_booking->participants[0]['gender'] : null,
|
||||||
|
'nationality_id' => isset($travel_booking->participants[0]['nationality']) ? $travel_booking->participants[0]['nationality'] : null,*/
|
||||||
|
'participant_name' => null,
|
||||||
|
'participant_firstname' => null,
|
||||||
|
'participant_birthdate' => null,
|
||||||
|
'participant_salutation_id' => null,
|
||||||
|
'nationality_id' => null,
|
||||||
|
'price' => $travel_booking->price,
|
||||||
|
'price_total' => $travel_booking->price_total,
|
||||||
|
'deposit_total' => $travel_booking->deposit_total,
|
||||||
|
'final_payment' => $travel_booking->final_payment,
|
||||||
|
'final_payment_date' => $travel_booking->final_payment_date->format('Y-m-d'),
|
||||||
|
'travel_country_id' => isset($travel_booking->selected_travel['travel_country_id'][0]) ? $travel_booking->selected_travel['travel_country_id'][0] : null,
|
||||||
|
'travel_category_id' => isset($travel_booking->selected_travel['travel_category_id']) ? $travel_booking->selected_travel['travel_category_id'] : null,
|
||||||
|
'travelagenda_id' => isset($travel_booking->selected_travel['travelagenda_id']) ? $travel_booking->selected_travel['travelagenda_id'] : null,
|
||||||
|
'travel_company_id' => isset($travel_booking->selected_travel['travel_company_id']) ? $travel_booking->selected_travel['travel_company_id'] : 4,
|
||||||
|
'insurance_offer' => $travel_booking->insurance_offer,
|
||||||
|
];
|
||||||
|
|
||||||
|
//createBooking
|
||||||
|
$booking = Booking::create($data);
|
||||||
|
|
||||||
|
//createTraveler
|
||||||
|
if($travel_booking->participants){
|
||||||
|
foreach ($travel_booking->participants as $key => $participant){
|
||||||
|
Participant::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'participant_name' => $participant['last_name'],
|
||||||
|
'participant_firstname' => $participant['first_name'],
|
||||||
|
'participant_birthdate' => date( "Y-m-d", strtotime($participant['birthday'])),
|
||||||
|
'participant_salutation_id' => $participant['gender'],
|
||||||
|
'participant_child' => $participant['child'],
|
||||||
|
'nationality_id' =>$participant['nationality'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//createServiceItem //service_items
|
||||||
|
if($travel_booking->service_items){
|
||||||
|
foreach ($travel_booking->service_items as $key => $service_item){
|
||||||
|
BookingServiceItem::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'travel_company_id' => $service_item['travel_company_id'],
|
||||||
|
'service_price' => $service_item['service_price'],
|
||||||
|
'service_price_refund' => 0,
|
||||||
|
'commission' => $service_item['commission'],
|
||||||
|
'travel_date' => $service_item['travel_date'],
|
||||||
|
'name' => $service_item['name'],
|
||||||
|
'is_commission_locked' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//createServiceItem //insurances
|
||||||
|
if($travel_booking->insurances){
|
||||||
|
foreach ($travel_booking->insurances as $service_item){
|
||||||
|
BookingServiceItem::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'travel_company_id' => $service_item['travel_company_id'],
|
||||||
|
'service_price' => $service_item['price'],
|
||||||
|
'service_price_refund' => 0,
|
||||||
|
'commission' => $service_item['commission'],
|
||||||
|
'travel_date' => $service_item['travel_date'],
|
||||||
|
'name' => $service_item['name'],
|
||||||
|
'is_commission_locked' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//createArrangement
|
||||||
|
if($travel_booking->arrangements) {
|
||||||
|
foreach ($travel_booking->arrangements as $key => $arrangement){
|
||||||
|
Arrangement::create([
|
||||||
|
'state' => isset($arrangement['state']) ? $arrangement['state'] : null,
|
||||||
|
'begin' => isset($arrangement['end']) ? $arrangement['end'] : null,
|
||||||
|
'end' => isset($arrangement['end']) ? $arrangement['end'] : null,
|
||||||
|
'type_s' => isset($arrangement['type_s']) ? $arrangement['type_s'] : null,
|
||||||
|
'data_s' => isset($arrangement['data_s']) ? $arrangement['data_s'] : null,
|
||||||
|
'view_position' => isset($arrangement['view_position']) ? $arrangement['view_position'] : null,
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'type_id' => $arrangement['type_id'],
|
||||||
|
'in_pdf' => isset($arrangement['in_pdf']) ? $arrangement['in_pdf'] : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//createDrafts
|
||||||
|
|
||||||
|
if($travel_booking->drafts) {
|
||||||
|
$draftRepo = new DraftRepository($booking);
|
||||||
|
$draftRepo->create_drafts_from_booking($booking->id, $travel_booking->drafts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$travel_booking->crm_booking_id = $booking->id;
|
||||||
|
$travel_booking->save();
|
||||||
|
|
||||||
|
return $booking;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
254
dev/hotfix-2026-04-18/HOTFIX.md
Normal file
254
dev/hotfix-2026-04-18/HOTFIX.md
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
# Hotfix 2026-04-18 — Fehlende `booking.lead_id` bei neu importierten Buchungen
|
||||||
|
|
||||||
|
**Status:** Dokumentation des am 2026-04-17/18 aufgetretenen Live-Bugs und der durchgeführten Korrekturen.
|
||||||
|
**Zielsystem:** Live (`mein.sterntours.de`, Phase 1)
|
||||||
|
**Abhängigkeit:** Phase-1-Deploy vom 2026-04-17 — NICHT vermischen mit Phase-2-Deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Symptom
|
||||||
|
|
||||||
|
Auf Live wurden ab dem 2026-04-17 neu eingehende Anfragen über die API zwar in
|
||||||
|
`contact` (ehem. `customer`) und `lead` sauber angelegt, aber das zugehörige
|
||||||
|
`booking` blieb in den alten Views „frei schwebend":
|
||||||
|
|
||||||
|
- Die Buchung war im Kontakt-Detail sichtbar (über `booking.customer_id`).
|
||||||
|
- In der Lead-Ansicht tauchte sie NICHT auf (`lead -> bookings`).
|
||||||
|
- In der Booking-Liste wurde die Verknüpfung zum Lead nicht angezeigt.
|
||||||
|
- In der DB: `booking.lead_id = NULL` bei jedem neuen Datensatz seit Phase-1-Deploy.
|
||||||
|
|
||||||
|
Zusätzlich trat in Einzelfällen auf, dass ein importiertes Booking keine
|
||||||
|
Programm-Verknüpfung hatte (`title`, `travel_number`, `travelagenda_id` etc. leer).
|
||||||
|
|
||||||
|
## 2. Root Cause
|
||||||
|
|
||||||
|
Beim Phase-1-Deploy am 2026-04-17 ist die **Phase-2-Version** von
|
||||||
|
`app/Services/BookingImport.php` versehentlich mit auf Live gelandet. Diese
|
||||||
|
Datei enthält die für Phase 2 vorbereitete Zeile:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- 'lead_id' => $lead->id, // Phase 1 (korrekt für Live)
|
||||||
|
+ 'inquiry_id' => $lead->id, // Phase 2 (Workspace-Stand)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Live existiert die Spalte `booking.inquiry_id` (noch) nicht — sie entsteht
|
||||||
|
erst in Phase 2 durch das Rename `booking.lead_id → booking.inquiry_id`. Und
|
||||||
|
das Live-`Booking`-Model hat `'inquiry_id'` nicht in `$fillable`. Folge:
|
||||||
|
|
||||||
|
- `Booking::create(['inquiry_id' => 42, ...])` → Eloquent ignoriert das Feld
|
||||||
|
stumm (mass-assignment guard).
|
||||||
|
- `customer_id` wird trotzdem korrekt gesetzt → Kontakt sieht die Buchung.
|
||||||
|
- `lead_id` bleibt `NULL` → alle leadbasierten Views finden die Buchung nicht.
|
||||||
|
|
||||||
|
Warum Programm-Felder in Einzelfällen fehlten, ist davon unabhängig: wenn
|
||||||
|
`travel_booking.selected_travel` zum Import-Zeitpunkt leer/unvollständig war,
|
||||||
|
übernimmt `BookingImport` die sechs Programm-Felder (`title`, `travel_number`,
|
||||||
|
`travel_country_id`, `travel_category_id`, `travelagenda_id`,
|
||||||
|
`travel_company_id`) als `null`.
|
||||||
|
|
||||||
|
## 3. Verifikation des Root Cause
|
||||||
|
|
||||||
|
Git-Commit `5a74789 deplay phase 1` vs. aktueller Workspace-Stand
|
||||||
|
(`ba48745 phase 2 dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /workspace/mein.sterntours.de
|
||||||
|
git diff 5a74789 HEAD -- app/Services/BookingImport.php
|
||||||
|
# → Zeigt genau die `'lead_id'` ↔ `'inquiry_id'` Zeile.
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Live gegengecheckt mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "lead_id\|inquiry_id" app/Services/BookingImport.php
|
||||||
|
# → Live zeigte 'inquiry_id' (Bug bestätigt)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Sofort-Hotfix (erledigt)
|
||||||
|
|
||||||
|
### 4.1 Datei-Austausch
|
||||||
|
|
||||||
|
Die saubere Phase-1-Version von `BookingImport.php` wurde aus Commit `5a74789`
|
||||||
|
exportiert und liegt unter:
|
||||||
|
|
||||||
|
```
|
||||||
|
dev/hotfix-2026-04-18/BookingImport.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp dev/hotfix-2026-04-18/BookingImport.php \
|
||||||
|
user@live:/var/www/html/app/Services/BookingImport.php
|
||||||
|
|
||||||
|
# Auf Live:
|
||||||
|
php artisan cache:clear
|
||||||
|
php artisan view:clear
|
||||||
|
php artisan config:clear
|
||||||
|
composer dump-autoload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 DB-Reparatur für bereits verwaiste Buchungen
|
||||||
|
|
||||||
|
Vorschau-Query:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17';
|
||||||
|
|
||||||
|
SELECT b.id AS booking_id, b.customer_id, b.created_at AS booking_created,
|
||||||
|
l.id AS lead_candidate_id, l.created_at AS lead_created,
|
||||||
|
c.firstname, c.name, c.email
|
||||||
|
FROM booking b
|
||||||
|
LEFT JOIN lead l ON l.customer_id = b.customer_id
|
||||||
|
LEFT JOIN customer c ON c.id = b.customer_id
|
||||||
|
WHERE b.lead_id IS NULL
|
||||||
|
AND b.created_at >= '2026-04-17'
|
||||||
|
ORDER BY b.id DESC;
|
||||||
|
|
||||||
|
-- Sanity-Check: eindeutige 1:1-Zuordnung Customer ↔ Lead?
|
||||||
|
SELECT customer_id, COUNT(*) AS lead_count
|
||||||
|
FROM lead
|
||||||
|
WHERE customer_id IN (
|
||||||
|
SELECT customer_id FROM booking WHERE lead_id IS NULL AND created_at >= '2026-04-17'
|
||||||
|
)
|
||||||
|
GROUP BY customer_id
|
||||||
|
HAVING lead_count > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Reparatur (nur wenn Sanity-Check leer):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE booking b
|
||||||
|
INNER JOIN lead l ON l.customer_id = b.customer_id
|
||||||
|
SET b.lead_id = l.id
|
||||||
|
WHERE b.lead_id IS NULL
|
||||||
|
AND b.created_at >= '2026-04-17';
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei betroffene Buchungen wurden am 2026-04-18 so manuell nachgetragen (vom User).
|
||||||
|
|
||||||
|
## 5. Reparatur-Toolkit für zukünftige Ausfälle
|
||||||
|
|
||||||
|
Zwei Artisan-Commands wurden gebaut, um ähnliche Import-Probleme gezielt
|
||||||
|
abhandeln zu können (siehe `app/Console/Commands/`). Beide sind **nicht Teil
|
||||||
|
von Phase 2** — reine Live-Werkzeuge, die sowohl auf Phase 1 als auch auf
|
||||||
|
Phase 2 funktionieren (sie nutzen das bestehende `BookingImport`).
|
||||||
|
|
||||||
|
### 5.1 `bookings:resync-from-travel-bookings`
|
||||||
|
|
||||||
|
**Use Case:** `travel_booking.crm_booking_id IS NULL` — Import ist nie
|
||||||
|
durchgelaufen oder mittendrin abgebrochen. Zieht das komplette Booking
|
||||||
|
(Customer + Lead + Booking + Participants + Arrangements + ServiceItems +
|
||||||
|
Drafts) frisch nach.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-Run (immer zuerst):
|
||||||
|
php artisan bookings:resync-from-travel-bookings --days=14
|
||||||
|
|
||||||
|
# Mit Anwenden:
|
||||||
|
php artisan bookings:resync-from-travel-bookings --days=14 --apply
|
||||||
|
|
||||||
|
# Einzelfall:
|
||||||
|
php artisan bookings:resync-from-travel-bookings --id=12345 --apply
|
||||||
|
|
||||||
|
# Plus Orphan-Check:
|
||||||
|
php artisan bookings:resync-from-travel-bookings --days=30 --include-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder Import in eigener Transaktion — Fehler in einem Datensatz stoppt die
|
||||||
|
anderen nicht.
|
||||||
|
|
||||||
|
### 5.2 `bookings:repair-from-travel-bookings`
|
||||||
|
|
||||||
|
**Use Case:** `crm_booking_id` ist gesetzt, aber einzelne Programm-Felder im
|
||||||
|
Booking fehlen (z. B. `title`, `travel_number`, `travelagenda_id`). Macht nur
|
||||||
|
ein Partial-Update der sechs Programm-Felder.
|
||||||
|
|
||||||
|
| booking-Spalte | Quelle in `travel_booking.selected_travel` |
|
||||||
|
|---|---|
|
||||||
|
| `title` | `travel_title` |
|
||||||
|
| `travel_number` | `travel_number` |
|
||||||
|
| `travel_country_id` | `travel_country_id[0]` |
|
||||||
|
| `travel_category_id` | `travel_category_id` |
|
||||||
|
| `travelagenda_id` | `travelagenda_id` |
|
||||||
|
| `travel_company_id` | `travel_company_id` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-Run:
|
||||||
|
php artisan bookings:repair-from-travel-bookings --days=14
|
||||||
|
|
||||||
|
# Anwenden:
|
||||||
|
php artisan bookings:repair-from-travel-bookings --days=14 --apply
|
||||||
|
|
||||||
|
# Gezielt auf CRM-Booking-ID:
|
||||||
|
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply
|
||||||
|
|
||||||
|
# Auch bereits gesetzte Felder überschreiben:
|
||||||
|
php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Default-Policy: **nur leere/NULL-Felder füllen**. Manuell gesetzte Werte
|
||||||
|
bleiben unangetastet. Mit `--force` werden auch bestehende Werte überschrieben.
|
||||||
|
|
||||||
|
### 5.3 Entscheidungshilfe
|
||||||
|
|
||||||
|
| Situation | Tool |
|
||||||
|
|---|---|
|
||||||
|
| `travel_booking.crm_booking_id IS NULL` | `bookings:resync-from-travel-bookings` |
|
||||||
|
| Buchung existiert, nur Programm-Felder leer | `bookings:repair-from-travel-bookings` |
|
||||||
|
| Buchung existiert, aber viele Felder / Teilnehmer fehlen | CRM-Booking löschen → `travel_booking.crm_booking_id` auf NULL → `bookings:resync-…` |
|
||||||
|
|
||||||
|
## 6. Deploy dieser Hotfix-Tools auf Live
|
||||||
|
|
||||||
|
Beide Commands sind selbst-enthaltene PHP-Dateien, keine DB-Änderung nötig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp app/Console/Commands/BookingsResyncFromTravelBookings.php \
|
||||||
|
user@live:/var/www/html/app/Console/Commands/BookingsResyncFromTravelBookings.php
|
||||||
|
|
||||||
|
scp app/Console/Commands/BookingsRepairFromTravelBookings.php \
|
||||||
|
user@live:/var/www/html/app/Console/Commands/BookingsRepairFromTravelBookings.php
|
||||||
|
|
||||||
|
# Auf Live:
|
||||||
|
php artisan list | grep "bookings:"
|
||||||
|
# → muss beide Commands zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Konsequenzen für Phase-2-Deploy
|
||||||
|
|
||||||
|
Dieser Vorfall hat gezeigt, dass beim Phase-1-Deploy am 2026-04-17 mindestens
|
||||||
|
eine Phase-2-Datei mit rübergerutscht ist. Vor Phase-2-Deploy MUSS daher
|
||||||
|
zusätzlich zum bestehenden `UPLOAD-LIST`-Verfahren ein expliziter Scan
|
||||||
|
stattfinden — siehe Ergänzung im Phase-2-Deploy-Handbuch.
|
||||||
|
|
||||||
|
**Sanity-Check vor jedem zukünftigen Phase-Deploy:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf Live, nach dem Upload:
|
||||||
|
grep -rn "inquiry_id\|'contacts'\b\|'inquiries'\b" app/ 2>/dev/null | head -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn nach Phase-2-Deploy alle Treffer auf die erwarteten Stellen zeigen (und
|
||||||
|
nicht auf nicht-gelistete Dateien), ist der Deploy sauber. Ein analoger
|
||||||
|
Gegencheck vor Phase 2 (dort dürfen keine Phase-2-Tokens existieren außer
|
||||||
|
in `BookingImport.php`, falls dieser Hotfix-Stand noch nicht ersetzt wurde).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anhang: Inhalt dieses Ordners
|
||||||
|
|
||||||
|
```
|
||||||
|
dev/hotfix-2026-04-18/
|
||||||
|
├── HOTFIX.md ← Dieses Dokument
|
||||||
|
└── BookingImport.php ← Phase-1-Version aus Commit 5a74789 (Deploy-Ready)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die beiden neuen Artisan-Commands liegen im normalen Code-Baum:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Console/Commands/BookingsResyncFromTravelBookings.php
|
||||||
|
app/Console/Commands/BookingsRepairFromTravelBookings.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Sie gehören dauerhaft zum Projekt und sollen auch nach Phase-2-Deploy
|
||||||
|
bestehen bleiben (sie funktionieren auf beiden Schemas).
|
||||||
|
|
@ -1,16 +1,127 @@
|
||||||
# Modul 6 — Angebote: Implementierungs-Tickets
|
# Modul 6 — Angebote: Implementierungs-Tickets
|
||||||
|
|
||||||
**Status:** Entwurf, bereit zur Umsetzung
|
**Status:** In Umsetzung (A7 abgeschlossen, Phase B als Nächstes)
|
||||||
**Erstellt:** April 2026
|
**Erstellt:** April 2026
|
||||||
|
**Letzte Aktualisierung:** 2026-04-24
|
||||||
**Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md)
|
**Konzept:** [../entwicklungsplan.md §6](../entwicklungsplan.md)
|
||||||
**Abhängigkeiten-Check vor Start:**
|
**Abhängigkeiten-Check vor Start:**
|
||||||
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren.
|
- Modul 3 (Customer/Lead/Booking) Phase 2 produktiv → `contacts` + `inquiries` existieren. ✅ Test
|
||||||
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`.
|
- Modul 3 Phase 4 (`attachments`, `communications`) ideal — sonst Fallback mit eigener `offer_files`-Tabelle, später Migration in `attachments`. ⬜ Fallback gewählt
|
||||||
- Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen.
|
- Modul 4 (Backend-Direktbuchung) Service `BookingService::createManual(...)` verfügbar (für Konvertierung Angebot → Buchung ohne Copy-Code). Kann parallel entwickelt werden, muss vor Ticket B8 stehen.
|
||||||
- Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar.
|
- Modul 1 (UI-Baseline) definiert: Blade-Components `ui.toolbar`, `ui.card`, `ui.tabs`, `ui.modal`, `ui.form-row` verfügbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Fortschritt
|
||||||
|
|
||||||
|
| Ticket | Status | Datum | Notizen |
|
||||||
|
|--------|--------|-------|---------|
|
||||||
|
| A1 — Datenbank-Migrationen (7 Files) | ✅ Test (Batch 34) | 2026-04-18 | FK-Typ-Fix nötig, siehe A1-Lessons |
|
||||||
|
| A2 — Eloquent-Models (6 Files) | ✅ Test | 2026-04-18 | Smoke-Test grün, siehe A2-Lessons |
|
||||||
|
| A3 — Repositories (4 Files) | ✅ Test | 2026-04-18 | Smoke-Test 9/9 grün, siehe A3-Lessons |
|
||||||
|
| A4 — OfferService + Exceptions + Events | ✅ Test | 2026-04-18 | Smoke-Test 13/13 grün, siehe A4-Lessons |
|
||||||
|
| A5 — FormRequests (4 Files) | ✅ | 2026-04-18 | `app/Http/Requests/Offer/*`, siehe A5-Lessons |
|
||||||
|
| A6 — Routing + Permissions + Sidenav | ✅ | 2026-04-24 | 15 Routen, 5 Rechte, siehe A6-Lessons |
|
||||||
|
| A7 — Factories + Seeder | ✅ | 2026-04-24 | `ContactFactory` + 4 Offer-Factories + `OfferTemplateSeeder`, siehe A7-Lessons |
|
||||||
|
|
||||||
|
### A1 — Lessons Learned (Test-Migration 2026-04-18)
|
||||||
|
|
||||||
|
1. **FK-Typ-Mismatch:** Spec/erste Version nutzte `foreignId(...)` → `bigint unsigned`. Die Legacy-Tabellen haben aber:
|
||||||
|
- `contacts.id`, `inquiries.id`, `booking.id`, `branch.id` → `bigint` **(signed!)**
|
||||||
|
- `users.id` → `int unsigned`
|
||||||
|
|
||||||
|
Fix in 3 Migrationen (100001, 100002, 100004): explizit `$t->bigInteger('contact_id')` etc. + manueller `$t->foreign(...)->references('id')->on('xxx')`. Für `users` → `unsignedInteger('created_by')`.
|
||||||
|
|
||||||
|
2. **Migrations-Pfad-Trick:** Das Projekt hat viele alte Pending-Migrationen, deren Tabellen längst existieren (Sequel-Pro-Export-Historie). `php artisan migrate --force` (ohne Pfad) schlägt deswegen fehl. Workaround:
|
||||||
|
```bash
|
||||||
|
mkdir -p storage/migrations-tmp/offers
|
||||||
|
for f in database/migrations/2026_04_17_*.php; do
|
||||||
|
ln -sf "$(realpath $f)" "storage/migrations-tmp/offers/$(basename $f)"
|
||||||
|
done
|
||||||
|
php artisan migrate --path=storage/migrations-tmp/offers --realpath --force
|
||||||
|
```
|
||||||
|
So werden ausschließlich die 7 Offer-Files geladen.
|
||||||
|
|
||||||
|
3. **Class-Name-Bug entdeckt & gefixt:** `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php` deklarierte fälschlicherweise `class CreateBookingVoucherTable` (richtig: `CreateBookingVoucherAgencyTable`). Korrigiert + Migrations-Tracker-Eintrag nachgetragen. Diese Datei wird beim nächsten Live-Deploy mitgehen.
|
||||||
|
|
||||||
|
4. **Verifikation OK:** 15 neue FKs gesetzt (5 auf `offers`, 3 auf `offer_versions`, je 1–2 auf andere + 1 Reverse auf `booking.offer_id`), Indizes/Unique-Constraints alle vorhanden. Siehe `SHOW CREATE TABLE`-Outputs in den Smoke-Tests.
|
||||||
|
|
||||||
|
5. **Akzeptanzkriterium „migrate:fresh --seed reproduzierbar":** Auf Test nicht ausführbar (würde DB löschen). Empfehlung: in CI auf einer Throwaway-DB testen, sobald CI-Pipeline existiert.
|
||||||
|
|
||||||
|
### Live-Deploy von A1 (TODO später)
|
||||||
|
|
||||||
|
Wenn A2-A7 fertig sind und das Modul deploybar ist, müssen für Live mit:
|
||||||
|
|
||||||
|
- 7 Migrationen aus `database/migrations/2026_04_17_100001` bis `100007`
|
||||||
|
- 1 Klassennamen-Fix aus `database/migrations/2020_04_08_094515_create_booking_voucher_agency_table.php`
|
||||||
|
|
||||||
|
ein eigenes Live-Deploy-Handbuch geschrieben werden (analog `phase-2-live-deploy.md`).
|
||||||
|
|
||||||
|
### A2 — Lessons Learned (2026-04-18)
|
||||||
|
|
||||||
|
1. **6 Models angelegt**: `Offer`, `OfferVersion`, `OfferItem`, `OfferTemplate`, `OfferFile`, `OfferAccessToken` in `app/Models/`. Alle mit `HasFactory`, `$connection='mysql'`, typisierten PHPDoc-Annotations, `STATUS_*`-Konstanten (statt String-Literals) und sauberen `BelongsTo`/`HasMany`-Relations.
|
||||||
|
2. **Status-Konstanten:** Stati sind als `self::STATUS_DRAFT` etc. konsistent verfügbar (Offer: 6 Stati, OfferVersion: 6 Stati inkl. `superseded`). Kein native PHP-Enum, weil das Projekt bislang keine Enums verwendet und `whereIn` mit Strings gut funktioniert.
|
||||||
|
3. **OfferAccessToken::generate(OfferVersion $v, ?Carbon $expiresAt = null): self** erzeugt `Str::random(64)`-Klartext, speichert SHA-256-Hash in DB, liefert Klartext als transiente Property `plain_token`. Counterpart `findActiveByPlainToken(string)` hasht den eingehenden Klartext und scoped auf `active()` (nicht widerrufen, nicht abgelaufen).
|
||||||
|
4. **OfferFile API-kompatibel zu BookingFile**: Gleiche Method-Signaturen (`getURL`, `getPath`, `getIconExt`, `formatBytes`) — so können Blade-Partials/Dropzone-Helper aus dem Booking-Modul geteilt werden. Unterschied: Spalte `include_in_pdf` statt `frozen` (semantisch klarer).
|
||||||
|
5. **Smoke-Test (Tinker, in Transaktion + Rollback)**: Offer + OfferVersion + OfferItem + OfferAccessToken angelegt, Eager-Loading `currentVersion.items / .tokens / contact` grün, `totalPriceFormatted() → "1.234,56 €"`, `isEditable() → true` für Draft.
|
||||||
|
6. **Offer-Inquiry-Relation zeigt auf Lead-Model**: Die Tabelle heißt nach Phase 2 `inquiries`, das Eloquent-Model ist aber weiterhin `App\Models\Lead` (Umbenennung des Models ist eigenes Ticket). Offer verwendet `$this->belongsTo(Lead::class, 'inquiry_id')`.
|
||||||
|
|
||||||
|
### A3 — Lessons Learned (2026-04-18)
|
||||||
|
|
||||||
|
1. **4 Repositories angelegt**: `OfferRepository`, `OfferVersionRepository`, `OfferTemplateRepository` (extends `BaseRepository`) und `OfferFileRepository` (extends `FileRepository`). Alle mit `use Illuminate\Support\Facades\DB;` — konsistent mit Phase-1-Stil der `ContactsMergeDuplicates`-Command.
|
||||||
|
2. **`generateOfferNumber()` race-safe umgesetzt**: `lockForUpdate()` auf der höchsten `offer_number LIKE '{year}-%'`-Zeile innerhalb einer `DB::transaction`; zusätzlicher Retry-Loop (3 Versuche, exponentielles Backoff) für den Fall eines UNIQUE-Constraint-Kollisionsfehlers. Format wie gefordert `YYYY-NNNNN`.
|
||||||
|
3. **`OfferRepository::create()` atomar**: Offer + Version#1 in einem `DB::transaction`-Block; bei Fehler volles Rollback (getestet). Das vermeidet „halbe Offers ohne Version" im DB.
|
||||||
|
4. **`createNewVersion()` dupliziert via `replicate()`**: Methode nutzt Eloquents `replicate(['except-cols'])`, um Items + Files als neue Zeilen zu kopieren. Physische Datei-Binaries werden **nicht** dupliziert — die Datei-Zeile bekommt nur eine neue DB-ID. Falls später einzelne Versionen gelöscht werden, muss die Delete-Logik prüfen, ob andere Versionen noch auf dieselbe Datei referenzieren (Aufgabe für B4/B5).
|
||||||
|
5. **`updateDraft()` wirft `DomainException` bei eingefrorener Version**: Smoke-Test 7 bestätigt das saubere Verhalten. Item-Sync ist destruktiv: IDs aus `$data['items']` werden aktualisiert, fehlende IDs neu angelegt, nicht mehr enthaltene Items gelöscht. `total_price` wird danach aus `SUM(items.total_price)` neu berechnet (nicht aus dem Request), damit UI- und DB-Summe immer konsistent sind.
|
||||||
|
6. **`OfferTemplate::applyTo()` ist behutsam**: Überschreibt nur **leere** Text-Felder (`?: $template->default_xxx`); Items der Version werden hingegen komplett ersetzt (das ist der Sinn einer Vorlage). Property-Access-Warnungen für globale `now()`/`\Storage`-Facades haben wir mit `Carbon::now()` bzw. expliziten Facade-Imports ausgehebelt — reine Intelephense-Quirks.
|
||||||
|
7. **Smoke-Test 9/9 grün**: Nummer generieren / atomares create / findByNumber / updateDraft mit Items (Summe 5.400 €) / Item-Sync (5.278 €) / markSent (+ Offer-Status auf sent) / updateDraft auf Sent wirft korrekt / createNewVersion (V1→superseded, V2 mit 3 kopierten Items, current_version umgeschoben, Offer zurück auf draft) / Template-anwenden (leere Texte übernommen, bestehender headline geschützt, 2 neue Items, Summe 2.119 €). Alles in einer Transaktion + Rollback.
|
||||||
|
|
||||||
|
### A4 — Lessons Learned (2026-04-18)
|
||||||
|
|
||||||
|
1. **`OfferService` mit DI-Konstruktor** (4 Repos injected). Kein Static-Helper wie `App\Services\Booking` — der Service gehört in den Container, ist damit testbar/mockbar und kann später auch Listener direkt auflösen.
|
||||||
|
2. **2 Custom Exceptions, sprechend:**
|
||||||
|
- `OfferNotEditableException(OfferVersion $v, string $action)` — trägt `versionId` + `currentStatus` als readonly Properties, Controller können fein unterscheiden.
|
||||||
|
- `InvalidOfferStatusException(Offer $o, array $expected, string $action)` — trägt `offerId`, `currentStatus`, `expectedStatuses[]`.
|
||||||
|
3. **4 Domain-Events** (`OfferSent`, `OfferAccepted`, `OfferDeclined`, `OfferWithdrawn`) in `app/Events/Offer/`. Dispatchen per `Event::dispatch()` — die Mail/Audit-Log-Arbeit machen Listener in Ticket B6 und später Modul 3 Phase 4 (Communications). Der Service selbst weiß nichts von Mail oder `customer_mails`.
|
||||||
|
4. **`send()` isoliert den Token-Lifecycle**: Erzeugt frischen Access-Token in derselben Transaktion wie den Status-Wechsel. Falls später die Mail scheitert, wird der Event-Listener die Queue-Retry machen — der Status-Übergang bleibt gültig. Guard: nur auf der `current_version_id`-Version, nur im Status `draft`.
|
||||||
|
5. **`supersede()` revoked alte Tokens**: Sauberer Invalidierungsweg bei Versionswechsel. Smoke-Test 8 bestätigt, dass der vor-dem-supersede aktive Token nach dem Call `revoked_at IS NOT NULL` hat — ein Aufruf der alten Kunden-URL würde somit ins Leere laufen.
|
||||||
|
6. **`markDeclined()` akzeptiert `sent` UND `accepted`**: Ein Kunde oder Admin kann auch nach erfolgter Zusage noch widersprechen/zurückziehen. Der Offer-Status wird dabei entsprechend auf `declined` gesetzt.
|
||||||
|
7. **`purgeExpired()` respektiert Offer-Hierarchie**: Setzt nur dann auch den Offer auf `expired`, wenn der Offer (a) noch `sent` ist und (b) die expirierte Version die `current_version_id` ist — sonst ist der Offer eh schon in einem anderen Lifecycle (z. B. in einer neueren Draft-Version).
|
||||||
|
8. **`convertToBooking()` nur als Guards+Stub**: Wirft sauber `InvalidOfferStatusException` (not accepted) und `DomainException` (double-invoke), aber der eigentliche Booking-Create hängt an `BookingService::createManual()` aus Modul 4 und wird in Ticket B8 finalisiert.
|
||||||
|
9. **Smoke-Test 13/13 grün**: createBlank + createBlank-mit-Template / updateVersion / send+Token / 2× Editable-Guard (updateVersion & send auf sent) / markAccepted + Double-Accept-Guard / supersede+Token-Revoke / withdraw + Double-Withdraw-Guard / convertToBooking-Guard / purgeExpired — alles in Transaktion + Rollback.
|
||||||
|
10. **Echte PHPUnit-Unit-Tests (Akzeptanzkriterium aus Spec) werden in Ticket F4 angelegt** (dort ist Coverage-Ziel ≥ 80 % für Service+Repo vereinbart). Der aktuelle Tinker-Smoke-Test liefert aber alle Pfad-Abdeckungen als Runbook, die F4 als Vorlage nutzen kann.
|
||||||
|
|
||||||
|
### A5 — Lessons Learned (2026-04-18)
|
||||||
|
|
||||||
|
1. **Erste `FormRequest`s im CRM-Projekt** (vorher keine `app/Http/Requests/`-Klasse). Muster: `Illuminate\Foundation\Http\FormRequest`, `authorize() =>` eingeloggter User, `rules()`, ggf. `withValidator` / `prepareForValidation`.
|
||||||
|
2. **StoreOfferRequest — Kreuz-Validierung**: Mindestens eines von `contact_id` / `inquiry_id` muss gesetzt sein (`withValidator` + Fehler auf `contact_id`). `template_id` mit `Rule::exists('offer_templates','id')->whereNull('deleted_at')` (keine gelöschte Vorlage).
|
||||||
|
3. **UpdateVersionRequest — WYSIWYG**: `intro_text` / `itinerary_text` / `closing_text` mit `strip_tags` + Whitelist; `headline` ohne Whitelist. Kein Mews/HtmlPurifier in `composer.json` — bei Bedarf nachziehen.
|
||||||
|
4. **Preise in Positionen**: `items.*.price_per_unit` = `numeric`; in `withValidator` für Typen außer `discount` Wert `>= 0` erzwingen.
|
||||||
|
5. **valid_until** → `after_or_equal:today` (nicht in der Vergangenheit). **SendOfferRequest** `expires_at` → `after:now` (für E-Mail-Token echter Zeitpunkt in der Zukunft).
|
||||||
|
6. **cc/bcc** als langer String; Aufteilen in einzelne Adressen erst im Mail-Listener (B6).
|
||||||
|
7. **Datei-Upload-Regeln (MIME wie BookingFile)** verbleiben am Upload-Pfad (wie `FileRepository` bei Buchungen) — in A6 kann bei Bedarf ein `UploadOfferFileRequest` ergänzt werden.
|
||||||
|
|
||||||
|
### A6 — Lessons Learned (2026-04-24)
|
||||||
|
|
||||||
|
1. **Routen im bestehenden `admin`+`2fa`-Block** (nicht `auth.2fa` der Spec — im Projekt heißt es `2fa` + `AdminMiddleware`). Permission-Gates mit `auth.permission:…` in drei Gruppen: `offers-r` (lesen + DataTable + `GET offer/action` + PDF-Stub), `offers-w` (POST detail, Löschen, File-Upload/Delete), `offer-templates-w` (Vorlagen-CRUD).
|
||||||
|
2. **5 neue Keys in `config/permissions.php`:** `offers-r`, `offers-w`, `offers-send`, `offers-accept`, `offer-templates-w`. Bestehende User haben die Keys **nicht automatisch** in `users.permissions` — nach Deploy im User-Rechte-UI setzen (SuperAdmin) oder JSON per SQL ergänzen; bei `setPermissionsDefault()` greifen sie nur, wenn `permissions` leer/ungültig ist.
|
||||||
|
3. **Fein-Permissions in `OfferController@action`:** pro `action` werden `offers-w` (Mutation), `offers-send` (send/resend), `offers-accept` (Zusage/Absage/withdraw) geprüft; unbekannte Aktionen verlangen `offers-w`.
|
||||||
|
4. **Menü** `layout-sidenav` zwischen „Anfragen“ und „Kunden“: sichtbar bei `offers-r` **oder** `offers-w` (nur-Writer-Edge-Case), Icon `ion-md-document`, aktive Pfade `offers` / `offer/*`.
|
||||||
|
5. **Stub-UI:** `resources/views/offer/index.blade.php` mit server-side DataTable (`data_table_offers`); `detail` + Vorlagen-Views + PDF sind Platzhalter für B1/B3/B5/C1.
|
||||||
|
6. **Disk `offer`** war bereits in `config/filesystems.php` — `OfferFileController` setzt `disk=offer` wie beim Booking-Upload.
|
||||||
|
|
||||||
|
### A7 — Lessons Learned (2026-04-24)
|
||||||
|
|
||||||
|
1. **`ContactFactory` ergänzt:** `Offer` verlangt `contact_id`; ohne Factory müsste jeder Test-Kontakt manuell angelegt werden. `Contact` hatte bereits `HasFactory`, es fehlte nur die Klassendatei unter `database/factories/`.
|
||||||
|
2. **`OfferFactory` + V1 ohne Rekursion:** Erste `OfferVersion` wird in `OfferFactory::afterCreating()` per `OfferVersion::query()->create()` angelegt und `current_version_id` gesetzt — kein `OfferVersion::factory()` mit verschachteltem `Offer::factory()` (unique `(offer_id, version_no)` und zyklische FKs).
|
||||||
|
3. **`OfferVersionFactory`:** Standalone-`definition()` nutzt `offer_id` → `Offer::factory()` und **`version_no` = 2**, weil `Offer::factory()` automatisch V1 erzeugt. Für manuelle Tests: `forOffer($offer, $n)` mit frei wählbarer Versionsnummer.
|
||||||
|
4. **States:** `Offer::factory()->sent()` / `->accepted()` setzen Offer-Status und synchronisieren die **aktuelle** Version (`refresh()->currentVersion`). `OfferVersionFactory` hat zusätzlich `versionSent()` / `versionAccepted()` für reine Versions-Snapshots.
|
||||||
|
5. **`OfferItemFactory`:** Hilfsmethoden `forCurrentVersionOf(Offer)` und `forVersion(OfferVersion)`; `asDiscount()` für Rabattzeilen. Erste Position nutzt `OfferItem::TYPE_TRAVEL`.
|
||||||
|
6. **`OfferTemplateSeeder`:** Fünf realistische Vorlagen per `updateOrCreate(['name' => …])`; `created_by` = kleinster existierender `users.id` (ohne `User::factory()` im Seeder, damit lokale/Stage-Seeds keinen Blind-User erzeugen). Leere User-Tabelle → Seeder bricht ab mit Warnung. Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`.
|
||||||
|
7. **Smoke-Test:** An dieser Umgebung war kein MySQL-Hostname erreichbar (`getaddrinfo for global-mysql`); Syntax-Check `php -l` grün. Lokal/CI mit laufender DB: `Offer::factory()->create()` + `OfferItem::factory()->forCurrentVersionOf($o)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. Übersicht
|
## 0. Übersicht
|
||||||
|
|
||||||
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **7–9 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
|
Insgesamt **6 Phasen** mit **32 Tickets**. Geschätzter Gesamtaufwand: **7–9 Wochen** (1 Entwickler). Aufsplittung erlaubt Parallelisierung von Phasen C + D + E nach Abschluss von B5.
|
||||||
|
|
@ -375,12 +486,14 @@ Route::group(['middleware' => ['auth.2fa']], function () {
|
||||||
**Abhängigkeiten:** A2
|
**Abhängigkeiten:** A2
|
||||||
|
|
||||||
**Dateien (neu):**
|
**Dateien (neu):**
|
||||||
|
- `database/factories/ContactFactory.php` (FK `offers.contact_id`)
|
||||||
- `database/factories/OfferFactory.php`
|
- `database/factories/OfferFactory.php`
|
||||||
- `database/factories/OfferVersionFactory.php`
|
- `database/factories/OfferVersionFactory.php`
|
||||||
- `database/factories/OfferItemFactory.php`
|
- `database/factories/OfferItemFactory.php`
|
||||||
- `database/factories/OfferTemplateFactory.php`
|
- `database/factories/OfferTemplateFactory.php`
|
||||||
|
- `database/seeds/OfferTemplateSeeder.php` (Namespace `Database\Seeders`, vgl. `composer.json` PSR-4 → `database/seeds/`)
|
||||||
|
|
||||||
Factories decken alle Status ab (States: `draft()`, `sent()`, `accepted()`). Seeder legt 5 Test-Templates an.
|
Factories decken zentrale Stati ab (u. a. `Offer::factory()->sent()` / `->accepted()`, plus Version-States in `OfferVersionFactory`). Seeder legt 5 Test-Templates in `offer_templates` an.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,55 @@
|
||||||
<div class="card mb-2 border-primary">
|
<div class="card mb-2 border-primary">
|
||||||
<h6 class="card-header bg-primary text-white py-2" data-toggle="collapse" data-target="#collapseBookingLead" aria-expanded="false" aria-controls="collapseBookingLead">
|
<h6 class="card-header bg-primary text-white py-2" data-toggle="collapse" data-target="#collapseBookingLead"
|
||||||
|
aria-expanded="false" aria-controls="collapseBookingLead">
|
||||||
<strong style="line-height: 1.6em">Anfrage</strong>
|
<strong style="line-height: 1.6em">Anfrage</strong>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="collapse" id="collapseBookingLead">
|
<div class="collapse" id="collapseBookingLead">
|
||||||
<div class="card-body row">
|
<div class="card-body row">
|
||||||
@if($booking->lead->count())
|
@if ($booking->lead)
|
||||||
<div class="form-group col-sm-6">
|
<div class="form-group col-sm-6">
|
||||||
<label class="form-label" for="lead_id">{{ __('Anfrage ID') }}</label>
|
<label class="form-label" for="lead_id">{{ __('Anfrage ID') }}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
<a class="text-primary" href="{{route('lead_detail', [$booking->lead->id])}}"><i class="fa fa-edit"></i></a></span>
|
<a class="text-primary" href="{{ route('lead_detail', [$booking->lead->id]) }}"><i
|
||||||
|
class="fa fa-edit"></i></a></span>
|
||||||
</div>
|
</div>
|
||||||
{{ Form::text('lead[id]', $booking->lead->id, array('placeholder'=>__('Anfrage ID'), 'class'=>'form-control', 'id'=>'lead_id', 'readonly')) }}
|
{{ Form::text('lead[id]', $booking->lead->id, ['placeholder' => __('Anfrage ID'), 'class' => 'form-control', 'id' => 'lead_id', 'readonly']) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group col-sm-6 col-md-6">
|
<div class="form-group col-sm-6 col-md-6">
|
||||||
<label class="form-label" for="lead_status_id">{{ __('Status ändern') }} </label>
|
<label class="form-label" for="lead_status_id">{{ __('Status ändern') }} </label>
|
||||||
{{ Form::select('lead[status_id]', \App\Models\Lead::getStatusArray() , $booking->lead->status_id, array('class'=>'custom-select')) }}
|
{{ Form::select('lead[status_id]', \App\Models\Lead::getStatusArray(), $booking->lead->status_id, ['class' => 'custom-select']) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="form-group col-sm-6">
|
<div class="form-group col-sm-6">
|
||||||
<label class="custom-control custom-checkbox mt-2">
|
<label class="custom-control custom-checkbox mt-2">
|
||||||
{!! Form::checkbox('lead[is_rebook]', 1, $booking->lead->is_rebook, ['class'=>'custom-control-input', 'readonly']) !!}
|
{!! Form::checkbox('lead[is_rebook]', 1, $booking->lead->is_rebook, [
|
||||||
<span class="custom-control-label">{{__('Umbuchung abgeschlossen')}}</span>
|
'class' => 'custom-control-input',
|
||||||
|
'readonly',
|
||||||
|
]) !!}
|
||||||
|
<span class="custom-control-label">{{ __('Umbuchung abgeschlossen') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group col-sm-6">
|
<div class="form-group col-sm-6">
|
||||||
<label class="custom-control custom-checkbox mt-2">
|
<label class="custom-control custom-checkbox mt-2">
|
||||||
{!! Form::checkbox('lead[is_closed]', 1, $booking->lead->is_closed, ['class'=>'custom-control-input', 'readonly']) !!}
|
{!! Form::checkbox('lead[is_closed]', 1, $booking->lead->is_closed, [
|
||||||
<span class="custom-control-label">{{__('Vorgang abgeschlossen')}}</span>
|
'class' => 'custom-control-input',
|
||||||
|
'readonly',
|
||||||
|
]) !!}
|
||||||
|
<span class="custom-control-label">{{ __('Vorgang abgeschlossen') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="text-left mt-2">
|
<div class="text-left mt-2">
|
||||||
<button type="submit" name="action" value="save_lead_status" class="btn btn-sm btn-secondary">Änderungen speichern</button>
|
<button type="submit" name="action" value="save_lead_status"
|
||||||
<a href="{{route('leads')}}" class="btn btn-sm btn-default">{{ __('zur Übersicht') }}</a>
|
class="btn btn-sm btn-secondary">Änderungen speichern</button>
|
||||||
|
<a href="{{ route('leads') }}" class="btn btn-sm btn-default">{{ __('zur Übersicht') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
@endif
|
@endif
|
||||||
@if (Auth::user()->isPermission('crm-bo'))
|
@if (Auth::user()->isPermission('crm-bo'))
|
||||||
<li
|
<li
|
||||||
class="sidenav-item{{ Request::is(['requests', 'bookings', 'booking/*', 'leads', 'lead/*', 'customers', 'customer/*', 'contacts', 'contact/*', 'customer_mails', 'customer_mail/*']) ? ' open' : '' }}">
|
class="sidenav-item{{ Request::is(['requests', 'bookings', 'booking/*', 'leads', 'lead/*', 'offers', 'offer/*', 'offer-templates', 'offer-template/*', 'customers', 'customer/*', 'contacts', 'contact/*', 'customer_mails', 'customer_mail/*']) ? ' open' : '' }}">
|
||||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||||
<i class="sidenav-icon ion ion-md-bed"></i>
|
<i class="sidenav-icon ion ion-md-bed"></i>
|
||||||
<div>Buchungen</div>
|
<div>Buchungen</div>
|
||||||
|
|
@ -93,6 +93,14 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
|
@if (Auth::user()->isPermission('offers-r') || Auth::user()->isPermission('offers-w'))
|
||||||
|
<li class="sidenav-item{{ Request::is(['offers', 'offer/*']) ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('offers') }}" class="sidenav-link"><i
|
||||||
|
class="sidenav-icon ion ion-md-document"></i>
|
||||||
|
<div>Angebote</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
@if (Auth::user()->isPermission('crm-bo-cu'))
|
@if (Auth::user()->isPermission('crm-bo-cu'))
|
||||||
<li
|
<li
|
||||||
class="sidenav-item{{ Request::is(['customers', 'customer/*']) ? ' active' : '' }}">
|
class="sidenav-item{{ Request::is(['customers', 'customer/*']) ? ' active' : '' }}">
|
||||||
|
|
|
||||||
13
resources/views/offer/detail.blade.php
Normal file
13
resources/views/offer/detail.blade.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h4 class="font-weight-bold py-3 mb-4">
|
||||||
|
{{ __('Angebot') }} {{ $offer->offer_number }} <small
|
||||||
|
class="text-muted">#{{ $offer->id }}</small>
|
||||||
|
</h4>
|
||||||
|
<div class="card p-3">
|
||||||
|
<p class="mb-0">Status: <span class="badge badge-secondary">{{ $offer->status }}</span></p>
|
||||||
|
<p class="text-muted small mb-0 mt-2">Detail-Editor, Tabs und Aktionen folgen in B3. Aktuelle
|
||||||
|
Version: V{{ $offer->currentVersion ? $offer->currentVersion->version_no : '—' }}.</p>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
57
resources/views/offer/index.blade.php
Normal file
57
resources/views/offer/index.blade.php
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h4 class="font-weight-bold py-3 mb-4">
|
||||||
|
{{ __('Angebote') }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-muted">Filter & Aktionen erweitert Ticket B1. Die Tabelle nutzt die Route <code>data_table_offers</code>.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive-track" id="datatables-offers-scroll">
|
||||||
|
<div class="table-responsive-thumb" id="datatables-offers-thumb"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-datatable table-responsive" id="datatables-offers-table">
|
||||||
|
<table class="datatables-offers table table-striped table-bordered" id="datatables-offers">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="max-width: 60px;"> </th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nr.</th>
|
||||||
|
<th>Kontakt</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Erstellt von</th>
|
||||||
|
<th>Erstellt am</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
var table = $('#datatables-offers').DataTable({
|
||||||
|
processing: true,
|
||||||
|
serverSide: true,
|
||||||
|
ajax: '{!! route('data_table_offers') !!}',
|
||||||
|
order: [[1, 'desc']],
|
||||||
|
columns: [
|
||||||
|
{data: 'action_edit', name: 'action_edit', orderable: false, searchable: false},
|
||||||
|
{data: 'id', name: 'offers.id'},
|
||||||
|
{data: 'offer_number', name: 'offers.offer_number'},
|
||||||
|
{data: 'contact_name', name: 'contact_name', orderable: false, searchable: false},
|
||||||
|
{data: 'status_badge', name: 'offers.status', orderable: true, searchable: false},
|
||||||
|
{data: 'created_name', name: 'created_name', orderable: false, searchable: false},
|
||||||
|
{data: 'created_at_fmt', name: 'offers.created_at', searchable: false}
|
||||||
|
],
|
||||||
|
bLengthChange: false,
|
||||||
|
iDisplayLength: 100,
|
||||||
|
language: {url: '/js/German.json'},
|
||||||
|
drawCallback: function () {
|
||||||
|
if (typeof dataTableScrollTrack === 'function') {
|
||||||
|
dataTableScrollTrack('#datatables-offers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
5
resources/views/offer/modal_new_offer_stub.blade.php
Normal file
5
resources/views/offer/modal_new_offer_stub.blade.php
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{{-- Stub für A6 — ersetzt durch B2 (modal-new-offer) --}}
|
||||||
|
<div class="p-2">
|
||||||
|
<p class="mb-0">Neues Angebot-Modal: Implementierung in Ticket B2
|
||||||
|
(<code>OfferService::createBlank / createFromInquiry</code>).</p>
|
||||||
|
</div>
|
||||||
14
resources/views/offer_template/detail.blade.php
Normal file
14
resources/views/offer_template/detail.blade.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h4 class="font-weight-bold py-3 mb-4">
|
||||||
|
@if($id === 'new')
|
||||||
|
{{ __('Neue Vorlage') }}
|
||||||
|
@else
|
||||||
|
{{ __('Vorlage') }}: {{ $template->name }}
|
||||||
|
@endif
|
||||||
|
</h4>
|
||||||
|
<div class="card p-3">
|
||||||
|
<p class="text-muted mb-0">Vorlagen-Editor folgt in C1.</p>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
14
resources/views/offer_template/index.blade.php
Normal file
14
resources/views/offer_template/index.blade.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h4 class="font-weight-bold py-3 mb-4">{{ __('Angebotsvorlagen') }}</h4>
|
||||||
|
<div class="list-group">
|
||||||
|
@forelse($templates as $t)
|
||||||
|
<a class="list-group-item list-group-item-action"
|
||||||
|
href="{{ route('offer_template_detail', ['id' => $t->id]) }}">{{ $t->name }}</a>
|
||||||
|
@empty
|
||||||
|
<p class="text-muted p-2 mb-0">Noch keine Vorlagen — anlegen in C1.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary mt-3" href="{{ route('offer_template_detail', ['id' => 'new']) }}">Neue Vorlage</a>
|
||||||
|
@endsection
|
||||||
|
|
@ -243,6 +243,28 @@ Route::group(['middleware' => ['admin', '2fa']], function () {
|
||||||
Route::post('lead/ajax/requests', 'LeadController@getAjaxRequests')->name('lead_ajax_requests');
|
Route::post('lead/ajax/requests', 'LeadController@getAjaxRequests')->name('lead_ajax_requests');
|
||||||
Route::get('/lead/delete/{id}/{del?}', 'LeadController@delete')->name('lead_delete');
|
Route::get('/lead/delete/{id}/{del?}', 'LeadController@delete')->name('lead_delete');
|
||||||
});
|
});
|
||||||
|
// Buchungen > Angebote (Modul 6)
|
||||||
|
Route::group(['middleware' => ['auth.permission:offers-r']], function () {
|
||||||
|
Route::get('data/table/offers', 'OfferController@getOffers')->name('data_table_offers');
|
||||||
|
Route::get('/offers/{step?}', 'OfferController@index')->name('offers');
|
||||||
|
Route::get('/offer/detail/{id}', 'OfferController@detail')->name('offer_detail');
|
||||||
|
Route::post('/offer/modal/load', 'OfferController@loadModal')->name('offer_modal_load');
|
||||||
|
Route::get('/offer/pdf/{versionId}/{do?}', 'OfferController@pdf')->name('offer_pdf');
|
||||||
|
Route::get('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action');
|
||||||
|
Route::post('/offer/action/{action}/{id?}', 'OfferController@action')->name('offer_action_store');
|
||||||
|
});
|
||||||
|
Route::group(['middleware' => ['auth.permission:offers-w']], function () {
|
||||||
|
Route::post('/offer/detail/{id}', 'OfferController@store')->name('offer_detail_store');
|
||||||
|
Route::get('/offer/delete/{id}/{del?}', 'OfferController@delete')->name('offer_delete');
|
||||||
|
Route::post('/offer/upload/{versionId}', 'OfferFileController@upload')->name('offer_file_upload');
|
||||||
|
Route::get('/offer/file/delete/{id}', 'OfferFileController@delete')->name('offer_file_delete');
|
||||||
|
});
|
||||||
|
Route::group(['middleware' => ['auth.permission:offer-templates-w']], function () {
|
||||||
|
Route::get('/offer-templates', 'OfferTemplateController@index')->name('offer_templates');
|
||||||
|
Route::get('/offer-template/detail/{id}', 'OfferTemplateController@detail')->name('offer_template_detail');
|
||||||
|
Route::post('/offer-template/detail/{id?}', 'OfferTemplateController@store')->name('offer_template_detail_store');
|
||||||
|
Route::get('/offer-template/delete/{id}', 'OfferTemplateController@delete')->name('offer_template_delete');
|
||||||
|
});
|
||||||
Route::group(['middleware' => ['auth.permission:crm-bo-cu']], function () {
|
Route::group(['middleware' => ['auth.permission:crm-bo-cu']], function () {
|
||||||
//Buchungen > Kunden (alt — bleibt erhalten)
|
//Buchungen > Kunden (alt — bleibt erhalten)
|
||||||
Route::get('data/table/customers', 'CustomerController@getCustomers')->name('data_table_customers');
|
Route::get('data/table/customers', 'CustomerController@getCustomers')->name('data_table_customers');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100001_create_offers_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100002_create_offer_versions_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100003_create_offer_items_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100004_create_offer_templates_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100005_create_offer_files_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100006_create_offer_access_tokens_table.php
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/mein.sterntours.de/database/migrations/2026_04_17_100007_add_offer_refs_to_offers_and_bookings.php
|
||||||
Loading…
Add table
Add a link
Reference in a new issue