Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
|
|
@ -13,31 +13,31 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Angebot (Modul 6).
|
||||
* Angebots-Kopfdatensatz (Modul 6 — Offers).
|
||||
*
|
||||
* Ein Offer ist der logische Angebots-Kopf (Angebotsnummer, Status,
|
||||
* Referenzen). Die Inhalte (Texte, Positionen, PDF) liegen versionsweise
|
||||
* in {@see OfferVersion}. Nach dem ersten Versand ist jede Änderung
|
||||
* eine neue Version (Entscheidung 17.1 Entwicklungsplan).
|
||||
* Trägt Angebotsnummer + Beziehungen zu Kontakt / Anfrage / Buchung und
|
||||
* zeigt via `current_version_id` auf die aktuell gültige OfferVersion.
|
||||
* Die eigentlichen Inhalte (Texte, Positionen, Preise, PDF) liegen in
|
||||
* `offer_versions`. Jede Änderung nach dem ersten Versand erzeugt eine
|
||||
* neue Version (siehe OfferVersion::isEditable()).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $offer_number
|
||||
* @property int $contact_id
|
||||
* @property int|null $inquiry_id
|
||||
* @property int|null $booking_id
|
||||
* @property string $status
|
||||
* @property int|null $current_version_id
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property-read Contact $contact
|
||||
* @property-read Lead|null $inquiry
|
||||
* @property-read Booking|null $booking
|
||||
* @property-read OfferVersion|null $currentVersion
|
||||
* @property int $id
|
||||
* @property string $offer_number
|
||||
* @property int $contact_id
|
||||
* @property int|null $inquiry_id
|
||||
* @property int|null $booking_id
|
||||
* @property string $status draft|sent|accepted|declined|expired|withdrawn
|
||||
* @property int|null $current_version_id
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property-read Contact $contact
|
||||
* @property-read Lead|null $inquiry
|
||||
* @property-read Booking|null $booking
|
||||
* @property-read Collection|OfferVersion[] $versions
|
||||
* @property-read Collection|OfferAccessToken[] $accessTokens
|
||||
* @property-read User $creator
|
||||
* @property-read OfferVersion|null $currentVersion
|
||||
* @property-read User $createdBy
|
||||
*/
|
||||
class Offer extends Model
|
||||
{
|
||||
|
|
@ -50,15 +50,13 @@ class Offer extends Model
|
|||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_WITHDRAWN = 'withdrawn';
|
||||
|
||||
public const STATUSES = [
|
||||
/** Stati, die als „nicht abgeschlossen" gelten (Offer läuft noch). */
|
||||
public const OPEN_STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_SENT,
|
||||
self::STATUS_ACCEPTED,
|
||||
self::STATUS_DECLINED,
|
||||
self::STATUS_EXPIRED,
|
||||
self::STATUS_WITHDRAWN,
|
||||
];
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offers';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -79,20 +77,30 @@ class Offer extends Model
|
|||
'created_by' => 'int',
|
||||
];
|
||||
|
||||
// ── Beziehungen ──────────────────────────────────────────────────────────
|
||||
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contact::class);
|
||||
return $this->belongsTo(Contact::class, 'contact_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Anfrage (Phase 2 heißt die Tabelle `inquiries`, das Eloquent-Model
|
||||
* ist weiterhin `Lead` — Umbenennung des Models ist eigenes Ticket).
|
||||
*/
|
||||
public function inquiry(): BelongsTo
|
||||
{
|
||||
// Nach Modul 3 Phase 2: `Lead`-Model bildet die `inquiries`-Tabelle ab
|
||||
return $this->belongsTo(Lead::class, 'inquiry_id');
|
||||
}
|
||||
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
return $this->belongsTo(Booking::class, 'booking_id');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferVersion::class, 'offer_id')->orderBy('version_no');
|
||||
}
|
||||
|
||||
public function currentVersion(): BelongsTo
|
||||
|
|
@ -100,33 +108,41 @@ class Offer extends Model
|
|||
return $this->belongsTo(OfferVersion::class, 'current_version_id');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferVersion::class)->orderBy('version_no');
|
||||
}
|
||||
|
||||
public function accessTokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferAccessToken::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeStatus(Builder $q, string $status): Builder
|
||||
// ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Nur noch nicht abgeschlossene Angebote (draft + sent). */
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $q->where('status', $status);
|
||||
return $query->whereIn('status', self::OPEN_STATUSES);
|
||||
}
|
||||
|
||||
public function scopeOpen(Builder $q): Builder
|
||||
public function scopeForContact(Builder $query, int $contactId): Builder
|
||||
{
|
||||
return $q->whereIn('status', [self::STATUS_DRAFT, self::STATUS_SENT]);
|
||||
return $query->where('contact_id', $contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $stati
|
||||
*/
|
||||
public function scopeWithStatus(Builder $query, array $stati): Builder
|
||||
{
|
||||
return $query->whereIn('status', $stati);
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SENT], true);
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return in_array($this->status, self::OPEN_STATUSES, true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,33 +10,39 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
|
||||
* Einmal-Token für den kundenseitigen Angebots-Freigabe-Link
|
||||
* (`/angebot/{token}` — siehe Phase D).
|
||||
*
|
||||
* In der Datenbank wird ausschließlich der SHA-256-Hash des Klartext-
|
||||
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
|
||||
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
|
||||
* per Mail-Link ausgeliefert.
|
||||
* In der Datenbank liegt nur der **SHA-256-Hash** des Tokens
|
||||
* (`token_hash`), nicht der Klartext — das minimiert das Risiko, wenn
|
||||
* die DB einmal abgegriffen wird. Der Klartext-Token wird ausschließlich
|
||||
* einmal von {@see self::generate()} zurückgegeben (transient in der
|
||||
* Property `plain_token`) und dann in der versendeten Mail an den
|
||||
* Kunden eingebettet.
|
||||
*
|
||||
* Pro Angebot + Version existiert genau ein aktiver Token; wird eine
|
||||
* neue Version versendet, setzt der OfferService den Vorgänger auf
|
||||
* `revoked_at`.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $offer_version_id
|
||||
* @property string $token_hash
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $offer_version_id
|
||||
* @property string $token_hash
|
||||
* @property Carbon|null $expires_at
|
||||
* @property Carbon|null $first_opened_at
|
||||
* @property Carbon|null $revoked_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read OfferVersion $version
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read OfferVersion $offerVersion
|
||||
*
|
||||
* Transient (nicht persistent, nur direkt nach generate()):
|
||||
* @property-read string|null $plain_token
|
||||
*/
|
||||
class OfferAccessToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** Anzahl der zufälligen Klartext-Zeichen (ergibt ≥ 48 bit Entropie deutlich). */
|
||||
public const TOKEN_LENGTH = 64;
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offer_access_tokens';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -56,65 +62,85 @@ class OfferAccessToken extends Model
|
|||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Transient — der ungehashte Token. Nur unmittelbar nach {@see generate()}
|
||||
* gesetzt, wird weder gespeichert noch serialisiert.
|
||||
*/
|
||||
public ?string $plain_token = null;
|
||||
|
||||
// ── Beziehungen ──────────────────────────────────────────────────────────
|
||||
|
||||
public function offer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Offer::class);
|
||||
return $this->belongsTo(Offer::class, 'offer_id');
|
||||
}
|
||||
|
||||
public function version(): BelongsTo
|
||||
public function offerVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $q): Builder
|
||||
// ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nur Tokens, die aktuell wirklich verwendet werden können:
|
||||
* nicht widerrufen, nicht abgelaufen.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $q->whereNull('revoked_at')
|
||||
return $query
|
||||
->whereNull('revoked_at')
|
||||
->where(function (Builder $q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein neues Token für die angegebene Version und liefert
|
||||
* den Klartext-Token zurück (nur einmalig abrufbar). In der
|
||||
* Datenbank wird nur der Hash persistiert.
|
||||
*/
|
||||
public static function generate(
|
||||
Offer $offer,
|
||||
OfferVersion $version,
|
||||
?Carbon $expiresAt = null
|
||||
): array {
|
||||
$plain = Str::random(48);
|
||||
$hash = hash('sha256', $plain);
|
||||
// ── Factories / Helper ───────────────────────────────────────────────────
|
||||
|
||||
/** @var self $token */
|
||||
$token = self::create([
|
||||
'offer_id' => $offer->id,
|
||||
/**
|
||||
* Erzeugt einen neuen Access-Token für eine Version. Der Klartext-Token
|
||||
* wird einmalig zurückgegeben (`$result->plain_token`), die DB speichert
|
||||
* nur den SHA-256-Hash.
|
||||
*
|
||||
* @param OfferVersion $version
|
||||
* @param Carbon|null $expiresAt null → kein Ablauf (bewusst auf Caller-Ebene entscheiden)
|
||||
*/
|
||||
public static function generate(OfferVersion $version, ?Carbon $expiresAt = null): self
|
||||
{
|
||||
$plain = Str::random(self::TOKEN_LENGTH);
|
||||
|
||||
$token = new self([
|
||||
'offer_id' => $version->offer_id,
|
||||
'offer_version_id' => $version->id,
|
||||
'token_hash' => $hash,
|
||||
'token_hash' => hash('sha256', $plain),
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
$token->save();
|
||||
|
||||
return ['plain' => $plain, 'token' => $token];
|
||||
$token->plain_token = $plain;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
|
||||
* Findet einen aktiven Token anhand seines Klartext-Werts (Mail-Link).
|
||||
* Gibt null zurück, wenn der Token unbekannt, widerrufen oder abgelaufen ist.
|
||||
*/
|
||||
public static function findByPlainToken(string $plain): ?self
|
||||
public static function findActiveByPlainToken(string $plain): ?self
|
||||
{
|
||||
return self::where('token_hash', hash('sha256', $plain))->first();
|
||||
$hash = hash('sha256', $plain);
|
||||
|
||||
return self::query()->active()->where('token_hash', $hash)->first();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
/**
|
||||
* Widerruft diesen Token — er ist danach nicht mehr nutzbar. Idempotent.
|
||||
*/
|
||||
public function revoke(): void
|
||||
{
|
||||
if ($this->revoked_at !== null) {
|
||||
return false;
|
||||
if ($this->revoked_at === null) {
|
||||
$this->revoked_at = now();
|
||||
$this->save();
|
||||
}
|
||||
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,32 +8,36 @@ use Illuminate\Database\Eloquent\Model;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Datei-Anhang einer Angebotsversion (Modul 6).
|
||||
* Datei-Anhang einer Angebotsversion.
|
||||
*
|
||||
* Struktur ist bewusst an {@see BookingFile} angelehnt (identifier,
|
||||
* filename, dir, original_name, ext, mine, size), damit der vorhandene
|
||||
* `FileRepository::store()` 1:1 wiederverwendet werden kann. `mine`
|
||||
* bleibt so geschrieben (statt `mime`) zur Konsistenz mit der
|
||||
* booking_files-Konvention.
|
||||
* Bewusst API-kompatibel mit {@see BookingFile} gehalten (gleiche
|
||||
* Method-Signaturen für `getURL()`, `getIconExt()`, `formatBytes()`,
|
||||
* `getPath()`), damit Blade-Partials wie `booking/modal-new-booking-files`
|
||||
* und die Dropzone-JS-Helfer direkt wiederverwendet werden können.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* Speichert in disk `offer` (Konvention: `storage/app/offer/YYYY/MM/…`).
|
||||
* Die Spalten-Schreibweise `mine` (statt `mime`) wurde aus BookingFile
|
||||
* übernommen, damit Frontend-Code identisch funktioniert.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* @property string|null $identifier
|
||||
* @property string $filename
|
||||
* @property string $dir
|
||||
* @property string $original_name
|
||||
* @property string $ext
|
||||
* @property string $mine
|
||||
* @property int $size
|
||||
* @property bool $include_in_pdf
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $version
|
||||
* @property string $filename
|
||||
* @property string $dir
|
||||
* @property string $original_name
|
||||
* @property string $ext
|
||||
* @property string $mine
|
||||
* @property int $size
|
||||
* @property bool $include_in_pdf
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $offerVersion
|
||||
*/
|
||||
class OfferFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offer_files';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -54,7 +58,11 @@ class OfferFile extends Model
|
|||
'include_in_pdf' => 'bool',
|
||||
];
|
||||
|
||||
public static array $iconExt = [
|
||||
/**
|
||||
* Icon-Klassen nach Extension (kompatibel zu BookingFile::$icon_ext).
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public static $icon_ext = [
|
||||
'default' => 'fa fa-file',
|
||||
'pdf' => 'fa fa-file-pdf',
|
||||
'jpg' => 'fa fa-file-image',
|
||||
|
|
@ -64,36 +72,45 @@ class OfferFile extends Model
|
|||
'docx' => 'fa fa-file-word',
|
||||
];
|
||||
|
||||
public function version(): BelongsTo
|
||||
public function offerVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function getIconExt(): string
|
||||
{
|
||||
return self::$iconExt[$this->ext] ?? self::$iconExt['default'];
|
||||
return self::$icon_ext[$this->ext] ?? self::$icon_ext['default'];
|
||||
}
|
||||
|
||||
public function getURL(bool|string $do = false): string
|
||||
/**
|
||||
* Download/Preview-URL. Der zweite Parameter der Storage-Route (`offer`)
|
||||
* wird vom `storage_file`-Controller für Disk-Auswahl ausgewertet —
|
||||
* muss serverseitig freigeschaltet sein (siehe Ticket B4 / StorageController).
|
||||
*
|
||||
* @param bool|string $download false = inline, 'download' = Attachment
|
||||
*/
|
||||
public function getURL($download = false): string
|
||||
{
|
||||
return route('storage_file', [$this->id, 'offer', $do]);
|
||||
return route('storage_file', [$this->id, 'offer', $download]);
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
// gleiches Idiom wie BookingFile::getPath() — Intelephense sieht
|
||||
// path() im Filesystem-Contract in diesem Kontext nicht zuverlässig,
|
||||
// php -l ist sauber; Methode ist Teil der Laravel-Filesystem-API seit 9.x.
|
||||
/** @phpstan-ignore-next-line */
|
||||
return \Storage::disk('offer')->path($this->dir . $this->filename);
|
||||
}
|
||||
|
||||
public function formatBytes(int $precision = 2): string
|
||||
{
|
||||
$size = $this->size;
|
||||
$size = (int) $this->size;
|
||||
if ($size <= 0) {
|
||||
return (string) $size;
|
||||
return '0 bytes';
|
||||
}
|
||||
|
||||
$base = log($size) / log(1024);
|
||||
$base = log($size) / log(1024);
|
||||
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
|
||||
|
||||
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
|
||||
return round(pow(1024, $base - floor($base)), $precision) . $suffixes[(int) floor($base)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,29 +8,31 @@ use Illuminate\Database\Eloquent\Model;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Position einer Angebotsversion (Modul 6).
|
||||
* Eine einzelne Positionszeile einer Angebotsversion (Leistung, Option,
|
||||
* Rabatt, Versicherung, …). Positionen sind versionsgebunden — eine neue
|
||||
* Version hat ihre eigenen Zeilen.
|
||||
*
|
||||
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
|
||||
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
|
||||
* `metadata` enthält einen Snapshot der Referenzdaten (Titel, Preis,
|
||||
* Leistungen) — so bleiben Positionen lesbar, auch wenn das Original
|
||||
* später gelöscht / migriert / umbenannt wird.
|
||||
* `travel_program_id` und `fewo_lodging_id` bleiben aktuell OHNE
|
||||
* Foreign-Key, solange die v2-Reiseverwaltung noch nicht nach Laravel
|
||||
* migriert ist (siehe Risiko R4 im Entwicklungsplan). Ein Snapshot
|
||||
* relevanter Quelldaten wird zusätzlich in `metadata` gespeichert,
|
||||
* damit Angebote auch nach einem v2-Schema-Wechsel lesbar bleiben.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* @property int $position
|
||||
* @property string $type
|
||||
* @property string $title
|
||||
* @property string|null $description
|
||||
* @property int $quantity
|
||||
* @property float $price_per_unit
|
||||
* @property float $total_price
|
||||
* @property int|null $travel_program_id
|
||||
* @property int|null $fewo_lodging_id
|
||||
* @property array|null $metadata
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $version
|
||||
* @property int $id
|
||||
* @property int $offer_version_id
|
||||
* @property int $position
|
||||
* @property string $type travel|service|option|discount|insurance|custom
|
||||
* @property string $title
|
||||
* @property string|null $description
|
||||
* @property int $quantity
|
||||
* @property float $price_per_unit
|
||||
* @property float $total_price
|
||||
* @property int|null $travel_program_id
|
||||
* @property int|null $fewo_lodging_id
|
||||
* @property array|null $metadata
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read OfferVersion $offerVersion
|
||||
*/
|
||||
class OfferItem extends Model
|
||||
{
|
||||
|
|
@ -43,6 +45,16 @@ class OfferItem extends Model
|
|||
public const TYPE_INSURANCE = 'insurance';
|
||||
public const TYPE_CUSTOM = 'custom';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_TRAVEL,
|
||||
self::TYPE_SERVICE,
|
||||
self::TYPE_OPTION,
|
||||
self::TYPE_DISCOUNT,
|
||||
self::TYPE_INSURANCE,
|
||||
self::TYPE_CUSTOM,
|
||||
];
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offer_items';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -63,24 +75,25 @@ class OfferItem extends Model
|
|||
'offer_version_id' => 'int',
|
||||
'position' => 'int',
|
||||
'quantity' => 'int',
|
||||
'price_per_unit' => 'decimal:2',
|
||||
'total_price' => 'decimal:2',
|
||||
'travel_program_id' => 'int',
|
||||
'fewo_lodging_id' => 'int',
|
||||
'price_per_unit' => 'decimal:2',
|
||||
'total_price' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function version(): BelongsTo
|
||||
public function offerVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus Menge × Einzelpreis den Positions-Gesamtpreis berechnen
|
||||
* (Rabatte negativ — gehört in den Service-Layer zur Summierung).
|
||||
* Berechnet `total_price` aus `quantity * price_per_unit` — wird vom
|
||||
* OfferService vor dem Speichern aufgerufen, damit UI-Aggregate
|
||||
* und DB-Werte konsistent bleiben.
|
||||
*/
|
||||
public function calculateTotal(): float
|
||||
public function recomputeTotal(): void
|
||||
{
|
||||
return round($this->quantity * (float) $this->price_per_unit, 2);
|
||||
$this->total_price = round($this->quantity * (float) $this->price_per_unit, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,33 +11,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Wiederverwendbare Angebots-Vorlage (Modul 6).
|
||||
* Vorlage (Blueprint) für neue Angebote — stellt Default-Texte und eine
|
||||
* Default-Positionsliste (`default_items`, JSON) bereit. Beim Anlegen
|
||||
* eines neuen Angebots über eine Vorlage werden diese Defaults in die
|
||||
* erste OfferVersion kopiert.
|
||||
*
|
||||
* Liefert Default-Texte + Default-Positionen für neue Angebote.
|
||||
* `default_items` ist ein JSON-Array von Positionen im Schema
|
||||
* [{title, description, type, price_per_unit, quantity}, …].
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $branch_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string|null $default_headline
|
||||
* @property string|null $default_intro
|
||||
* @property string|null $default_itinerary
|
||||
* @property string|null $default_closing
|
||||
* @property array|null $default_items
|
||||
* @property bool $is_active
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property int $id
|
||||
* @property int|null $branch_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string|null $default_headline
|
||||
* @property string|null $default_intro
|
||||
* @property string|null $default_itinerary
|
||||
* @property string|null $default_closing
|
||||
* @property array|null $default_items
|
||||
* @property bool $is_active
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property-read Branch|null $branch
|
||||
* @property-read User $creator
|
||||
* @property-read User $createdBy
|
||||
*/
|
||||
class OfferTemplate extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offer_templates';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -55,23 +55,33 @@ class OfferTemplate extends Model
|
|||
|
||||
protected $casts = [
|
||||
'branch_id' => 'int',
|
||||
'default_items' => 'array',
|
||||
'is_active' => 'bool',
|
||||
'created_by' => 'int',
|
||||
'is_active' => 'bool',
|
||||
'default_items' => 'array',
|
||||
];
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class);
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $q): Builder
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $q->where('is_active', true);
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForBranch(Builder $query, ?int $branchId): Builder
|
||||
{
|
||||
if ($branchId === null) {
|
||||
return $query;
|
||||
}
|
||||
return $query->where(function (Builder $q) use ($branchId) {
|
||||
$q->whereNull('branch_id')->orWhere('branch_id', $branchId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,38 +11,39 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Version eines Angebots (Modul 6).
|
||||
* Eine konkrete Version (Snapshot) eines Angebots.
|
||||
*
|
||||
* Jede versendete Fassung wird hier festgehalten — Texte, Positionen
|
||||
* und PDF bleiben damit unveränderlich, sobald ein Kunde sie per
|
||||
* Freigabe-Link einsehen kann. Neue Änderungen nach dem Versand
|
||||
* erzeugen eine neue Version (version_no = max+1, status = draft).
|
||||
* Alle inhaltlichen Felder (Texte, Preise, Gültigkeit, PDF-Pfad, Status,
|
||||
* Anhänge) hängen hier — nicht am Offer-Kopf. So bleiben bereits versendete
|
||||
* Versionen unveränderlich (`isEditable() === false`), während eine neue
|
||||
* Version ihre eigenen Texte/Positionen/Anhänge führt.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $version_no
|
||||
* @property string $status
|
||||
* @property Carbon|null $valid_until
|
||||
* @property float $total_price
|
||||
* @property string|null $headline
|
||||
* @property string|null $intro_text
|
||||
* @property string|null $itinerary_text
|
||||
* @property string|null $closing_text
|
||||
* @property int|null $template_id
|
||||
* @property string|null $pdf_path
|
||||
* @property bool $pdf_archived
|
||||
* @property Carbon|null $sent_at
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property string|null $accepted_via
|
||||
* @property array|null $template_document_ids
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read OfferTemplate|null $template
|
||||
* @property-read Collection|OfferItem[] $items
|
||||
* @property-read Collection|OfferFile[] $files
|
||||
* @property-read User $creator
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $version_no
|
||||
* @property string $status draft|sent|accepted|declined|expired|superseded
|
||||
* @property Carbon|null $valid_until
|
||||
* @property float $total_price
|
||||
* @property string|null $headline
|
||||
* @property string|null $intro_text
|
||||
* @property string|null $itinerary_text
|
||||
* @property string|null $closing_text
|
||||
* @property int|null $template_id
|
||||
* @property string|null $pdf_path
|
||||
* @property bool $pdf_archived
|
||||
* @property Carbon|null $sent_at
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property string|null $accepted_via customer_link|admin|email
|
||||
* @property array|null $template_document_ids JSON-Array von zentral hinterlegten Dokument-IDs
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read Offer $offer
|
||||
* @property-read Collection|OfferItem[] $items
|
||||
* @property-read Collection|OfferFile[] $files
|
||||
* @property-read Collection|OfferAccessToken[] $tokens
|
||||
* @property-read OfferTemplate|null $template
|
||||
* @property-read User $createdBy
|
||||
*/
|
||||
class OfferVersion extends Model
|
||||
{
|
||||
|
|
@ -57,8 +58,9 @@ class OfferVersion extends Model
|
|||
|
||||
public const ACCEPTED_VIA_LINK = 'customer_link';
|
||||
public const ACCEPTED_VIA_ADMIN = 'admin';
|
||||
public const ACCEPTED_VIA_MAIL = 'email';
|
||||
public const ACCEPTED_VIA_EMAIL = 'email';
|
||||
|
||||
protected $connection = 'mysql';
|
||||
protected $table = 'offer_versions';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -84,19 +86,36 @@ class OfferVersion extends Model
|
|||
protected $casts = [
|
||||
'offer_id' => 'int',
|
||||
'version_no' => 'int',
|
||||
'valid_until' => 'date',
|
||||
'total_price' => 'decimal:2',
|
||||
'template_id' => 'int',
|
||||
'pdf_archived' => 'bool',
|
||||
'created_by' => 'int',
|
||||
'total_price' => 'decimal:2',
|
||||
'valid_until' => 'date',
|
||||
'sent_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
'pdf_archived' => 'bool',
|
||||
'template_document_ids' => 'array',
|
||||
'created_by' => 'int',
|
||||
];
|
||||
|
||||
// ── Beziehungen ──────────────────────────────────────────────────────────
|
||||
|
||||
public function offer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Offer::class);
|
||||
return $this->belongsTo(Offer::class, 'offer_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferItem::class, 'offer_version_id')->orderBy('position');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferFile::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function tokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferAccessToken::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
|
|
@ -104,23 +123,52 @@ class OfferVersion extends Model
|
|||
return $this->belongsTo(OfferTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferItem::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(OfferFile::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Darf diese Version noch bearbeitet werden?
|
||||
* Nur Draft-Versionen sind editierbar; alle anderen sind eingefroren.
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den zuletzt erzeugten, noch nicht widerrufenen Token
|
||||
* (falls vorhanden) — wird im Freigabe-Link verwendet.
|
||||
*/
|
||||
public function latestToken(): ?OfferAccessToken
|
||||
{
|
||||
return $this->tokens()
|
||||
->whereNull('revoked_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatierter Preis („1.234,56 €"), konsistent mit der Darstellung
|
||||
* im Buchungsdetail.
|
||||
*/
|
||||
public function totalPriceFormatted(): string
|
||||
{
|
||||
return number_format((float) $this->total_price, 2, ',', '.') . ' €';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die URL auf das zuletzt erzeugte PDF dieser Version zurück,
|
||||
* oder null, wenn noch kein PDF existiert.
|
||||
*/
|
||||
public function getPdfUrl(bool $download = false): ?string
|
||||
{
|
||||
if (! $this->pdf_path) {
|
||||
return null;
|
||||
}
|
||||
return route('offer_pdf', ['versionId' => $this->id, 'do' => $download ? 'download' : null]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -569,6 +569,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getInvoicePathFile(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getInvoiceFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||
return Storage::disk('fewo_invoices')->path($dir.$filename);
|
||||
}
|
||||
|
|
@ -578,6 +581,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getInvoiceUrlFile(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getInvoiceFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||
return Storage::disk('fewo_invoices')->url($dir.$filename);
|
||||
}
|
||||
|
|
@ -587,6 +593,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getInvoiceLastModified(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getInvoiceFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->format("H:i d.m.Y");
|
||||
|
||||
|
|
@ -598,6 +607,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function isChangeLowerInvoiceCreate(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getInvoiceFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||
return Carbon::createFromTimestamp(Storage::disk('fewo_invoices')->lastModified($dir.$filename))->gt($this->last_change_at);
|
||||
}
|
||||
|
|
@ -645,6 +657,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getTravelInfoPathFile(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getTravelInfoFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||
return Storage::disk('fewo_infos')->path($dir.$filename);
|
||||
}
|
||||
|
|
@ -654,6 +669,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getTravelInfoUrlFile(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getTravelInfoFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||
return Storage::disk('fewo_infos')->url($dir.$filename);
|
||||
}
|
||||
|
|
@ -663,6 +681,9 @@ class TravelUserBookingFewo extends Model
|
|||
public function getTravelInfoLastModified(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getTravelInfoFileName();
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||
return Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->format("H:i d.m.Y");
|
||||
}
|
||||
|
|
@ -672,7 +693,10 @@ class TravelUserBookingFewo extends Model
|
|||
public function isChangeLowerTravelInfoCreate(){
|
||||
$dir = $this->getBookingDateYear()."/";
|
||||
$filename = $this->getTravelInfoFileName();
|
||||
if(Storage::disk('fewo_invoices')->exists( $dir.$filename )){
|
||||
if(!$filename){
|
||||
return false;
|
||||
}
|
||||
if(Storage::disk('fewo_infos')->exists( $dir.$filename )){
|
||||
return Carbon::createFromTimestamp(Storage::disk('fewo_infos')->lastModified($dir.$filename))->gt($this->last_change_at);
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue