phase 2 dev

This commit is contained in:
Kevin Adametz 2026-04-22 16:01:27 +02:00
parent 5a7478907e
commit ba48745809
59 changed files with 2692 additions and 1994 deletions

View file

@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
* @property int $id
* @property Carbon $booking_date
* @property int $customer_id
* @property int $lead_id
* @property int $inquiry_id
* @property bool $new_drafts
* @property int $sf_guard_user_id
* @property int $branch_id
@ -212,7 +212,8 @@ class Booking extends Model
protected $casts = [
'customer_id' => 'int',
'lead_id' => 'int',
'inquiry_id' => 'int',
'offer_id' => 'int',
'new_drafts' => 'bool',
'sf_guard_user_id' => 'int',
'branch_id' => 'int',
@ -256,7 +257,8 @@ class Booking extends Model
protected $fillable = [
'booking_date',
'customer_id',
'lead_id',
'inquiry_id',
'offer_id',
'new_drafts',
'sf_guard_user_id',
'branch_id',
@ -393,9 +395,29 @@ class Booking extends Model
return $this->belongsTo(Customer::class);
}
/**
* Lead/Inquiry der Buchung.
* FK-Spalte `inquiry_id` (vormals `lead_id` Modul 3 Phase 2 Rename).
* Methodenname bleibt `lead()` für Legacy-Kompatibilität; {@see self::inquiry()}
* ist der fachlich korrekte Alias und sollte in neuem Code verwendet werden.
*/
public function lead()
{
return $this->belongsTo(Lead::class);
return $this->belongsTo(Lead::class, 'inquiry_id');
}
public function inquiry()
{
return $this->belongsTo(Lead::class, 'inquiry_id');
}
/**
* Angebot, aus dem diese Buchung entstanden ist (Modul 6, Ticket B8).
* Nullable nicht jede Buchung hat einen Angebots-Vorlauf.
*/
public function offer()
{
return $this->belongsTo(\App\Models\Offer::class);
}
public function sf_guard_user()
@ -765,8 +787,6 @@ class Booking extends Model
$total_children += $prices['children'];
}
if ($travel_draft_item) {
$travel_draft_item->setPriceAdultRaw($travel_price_adult);
$travel_draft_item->setPriceChildrenRaw($travel_price_children);
@ -775,10 +795,23 @@ class Booking extends Model
$travel_draft_item->save();
}
$this->price = $total_adult + $total_children;
$this->price_total = $this->getPriceRaw() + $this->getServiceTotal(true);
$this->setPriceTotalForCurrentState();
$this->save();
}
/**
* Gesamtpreis Reise (price_total): bei Storno mit gesetztem Storno-Betrag = price_canceled
* (wie nach createPDF_Storno in BookingPDFRepository), sonst Reisepreis + Vermittlung.
*/
public function setPriceTotalForCurrentState(): void
{
if ($this->isCanceled() && $this->attributes['price_canceled'] !== null) {
$this->price_total = round((float) $this->getPriceCanceledRaw(), 2);
return;
}
$this->price_total = round((float) $this->getPriceRaw() + (float) $this->getServiceTotal(true), 2);
}
public function getPriceAttribute()
{
return Util::_number_format($this->attributes['price']);

View file

@ -18,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* - merged_into_id + merged_at in $fillable
* - mergedInto() / mergedContacts() Beziehungen
*
* Tabellen-Name: 'customer' (wird in Phase 2 in 'contacts' umbenannt).
* Tabellen-Name: 'contacts' (nach Phase 2 RENAME TABLE customer contacts).
*
* @property int $id
* @property int|null $salutation_id
@ -50,7 +50,7 @@ class Contact extends Model
protected $connection = 'mysql';
protected $table = 'customer';
protected $table = 'contacts';
protected $casts = [
'salutation_id' => 'int',

View file

@ -88,7 +88,12 @@ class Customer extends Model
protected $connection = 'mysql';
protected $table = 'customer';
/**
* Modul 3 Phase 2: customer contacts (RENAME TABLE).
* Der Model-Name bleibt aus Kompatibilität zum Legacy-Code bestehen;
* für die Neuimplementierung steht {@see Contact} bereit.
*/
protected $table = 'contacts';
protected $casts = [
'salutation_id' => 'int',

View file

@ -108,11 +108,16 @@ use Illuminate\Database\Eloquent\Collection;
*/
class Lead extends Model
{
use HasFactory;
use HasFactory;
protected $connection = 'mysql';
protected $connection = 'mysql';
protected $table = 'lead';
/**
* Modul 3 Phase 2: lead inquiries (RENAME TABLE).
* Model-Name bleibt (um Breaking Changes in der gesamten Codebase zu vermeiden);
* fachlich ist das Modell jetzt eine "Inquiry" (Anfrage).
*/
protected $table = 'inquiries';
protected $casts = [
'customer_id' => 'int',
@ -121,8 +126,8 @@ class Lead extends Model
'travelagenda_id' => 'int',
'sf_guard_user_id' => 'int',
'is_closed' => 'bool',
'is_rebook' => 'bool',
'initialcontacttype_id' => 'int',
'is_rebook' => 'bool',
'initialcontacttype_id' => 'int',
'searchengine_id' => 'int',
'status_id' => 'int',
'website_id' => 'int',
@ -149,7 +154,7 @@ class Lead extends Model
'remarks',
'sf_guard_user_id',
'is_closed',
'is_rebook',
'is_rebook',
'initialcontacttype_id',
'searchengine_id',
'searchengine_keywords',
@ -164,24 +169,24 @@ class Lead extends Model
'participant_birthdate',
'participant_salutation_id'
];
protected $passolutionPDFs = [];
protected $passolutionPDFs = [];
public static $lead_mail_dirs = [
11 => ['name' => 'Entwürfe', 'icon'=>'ion-md-create'],
12 => ['name' => 'Papierkorb', 'icon'=>'ion-md-trash'],
];
11 => ['name' => 'Entwürfe', 'icon' => 'ion-md-create'],
12 => ['name' => 'Papierkorb', 'icon' => 'ion-md-trash'],
];
public function updateNextDueDate($date = false){
public function updateNextDueDate($date = false)
{
if(!$date){
$carbon = Carbon::now();
}else{
if (!$date) {
$carbon = Carbon::now();
} else {
$carbon = Carbon::parse($date);
}
$this->next_due_date = $carbon->modify('+ '.$this->status->handling_days.' days')->format("Y-m-d");
$this->next_due_date = $carbon->modify('+ ' . $this->status->handling_days . ' days')->format("Y-m-d");
$this->save();
}
}
public function customer()
{
return $this->belongsTo(Customer::class);
@ -222,18 +227,18 @@ class Lead extends Model
return $this->belongsTo(TravelCategory::class, 'travelcategory_id');
}
//on crm
public function travel_country_crm()
{
return $this->belongsTo('App\Models\Sym\TravelCountry', 'travelcountry_id', 'id');
}
//on crm
public function travel_country_crm()
{
return $this->belongsTo('App\Models\Sym\TravelCountry', 'travelcountry_id', 'id');
}
//on stern other DB
public function travel_country()
{
return $this->belongsTo('App\Models\TravelCountry', 'travelcountry_id', 'crm_id');
}
//on stern other DB
public function travel_country()
{
return $this->belongsTo('App\Models\TravelCountry', 'travelcountry_id', 'crm_id');
}
public function website()
@ -243,7 +248,8 @@ class Lead extends Model
public function bookings()
{
return $this->hasMany(Booking::class);
// Modul 3 Phase 2: FK heißt jetzt inquiry_id (vormals lead_id)
return $this->hasMany(Booking::class, 'inquiry_id');
}
public function inquiries()
@ -267,149 +273,156 @@ class Lead extends Model
}
public function lead_files()
{
{
//no lead_mail_id
return $this->hasMany(LeadFile::class, 'lead_id')->where('lead_mail_id', null);
}
return $this->hasMany(LeadFile::class, 'lead_id')->where('lead_mail_id', null);
}
public function lead_mails()
{
return $this->hasMany(LeadMail::class, 'lead_id', 'id');
}
public function lead_mails()
{
return $this->hasMany(LeadMail::class, 'lead_id', 'id');
}
public function lead_mails_sent_at()
{
return $this->hasMany(LeadMail::class, 'lead_id')->orderBy('sent_at', 'ASC');
}
public function lead_mails_sent_at()
{
return $this->hasMany(LeadMail::class, 'lead_id')->orderBy('sent_at', 'ASC');
}
public function lead_mail_last()
{
return $this->hasOne(LeadMail::class, 'lead_id')->latest();
}
public function lead_mail_last()
{
return $this->hasOne(LeadMail::class, 'lead_id')->latest();
}
public function lead_notices()
{
return $this->hasMany(LeadNotice::class, 'lead_id')->orderBy('updated_at', 'DESC');
}
public function lead_notices()
{
return $this->hasMany(LeadNotice::class, 'lead_id')->orderBy('updated_at', 'DESC');
}
public static function getSfGuardUserArray(){
return SfGuardUser::where('is_active', 1)->get()->pluck('fullname', 'id');
}
public static function getSfGuardUserArray()
{
return SfGuardUser::where('is_active', 1)->get()->pluck('fullname', 'id');
}
public static function getTravelCountryArray($emtpy = false){
public static function getTravelCountryArray($emtpy = false)
{
$TravelCountry = TravelCountry::where('active_backend', 1)->orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelCountry->prepend('-', 0) : $TravelCountry;
return $emtpy ? $TravelCountry->prepend('-', 0) : $TravelCountry;
}
}
public static function getTravelCategoryArray($emtpy = false){
public static function getTravelCategoryArray($emtpy = false)
{
$TravelCategory = TravelCategory::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelCategory->prepend('-', 0) : $TravelCategory;
return $emtpy ? $TravelCategory->prepend('-', 0) : $TravelCategory;
}
public static function getTravelAgendaArray($emtpy = false){
public static function getTravelAgendaArray($emtpy = false)
{
$TravelAgenda = TravelAgenda::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $TravelAgenda->prepend('-', 0) : $TravelAgenda;
return $emtpy ? $TravelAgenda->prepend('-', 0) : $TravelAgenda;
}
public static function getStatusArray($emtpy = false){
public static function getStatusArray($emtpy = false)
{
$Status = Status::orderBy('name')->get()->pluck('name', 'id');
return $emtpy ? $Status->prepend('-', 0) : $Status;
return $emtpy ? $Status->prepend('-', 0) : $Status;
}
public function getStatusBadge($booking = null)
{
if($this->status_id && $this->status){
if ($this->status_id && $this->status) {
$color = $this->status->color;
$icon = "";
if($this->status_id == 14 && $this->is_rebook){
if ($this->status_id == 14 && $this->is_rebook) {
$color = '#94ae59';
$icon = '<i class="fa fa-check-circle"></i> ';
}
if($this->status_id == 14 && !$this->is_rebook){
$icon = '<i class="fa fa-times-circle"></i> ';
if ($this->status_id == 14 && !$this->is_rebook) {
$icon = '<i class="fa fa-times-circle"></i> ';
}
if($this->status_id == 15){
if ($this->status_id == 15) {
$icon = '<i class="fa fa-balance-scale"></i> ';
if($booking && $booking->lawyer_date){
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$booking->lawyer_date->format('d.m.Y').'</span></span>';
if ($booking && $booking->lawyer_date) {
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $booking->lawyer_date->format('d.m.Y') . '</span></span>';
}
}
return '<span data-order="'.$this->status_id.'"><span class="badge badge-dark" style="background-color: '.$color.'">'.$icon.$this->status->name.'</span></span>';
return '<span data-order="' . $this->status_id . '"><span class="badge badge-dark" style="background-color: ' . $color . '">' . $icon . $this->status->name . '</span></span>';
}
return '<span data-order="0">-</span>';
}
public function getTravelCountryDestco($badge = true){
public function getTravelCountryDestco($badge = true)
{
$out = "";
if($this->bookings->count()){
if ($this->bookings->count()) {
$out .= $badge ? '<span class="badge badge-success">' : '';
foreach ($this->bookings as $booking){
if($booking->travel_country_id && $booking->travel_country) {
foreach ($this->bookings as $booking) {
if ($booking->travel_country_id && $booking->travel_country) {
$out .= $booking->travel_country->destco;
}
}
$out .= $badge ? '</span>' : '';
return $out;
}
if($this->travel_country){
return $badge ? '<span class="badge badge-secondary">'.$this->travel_country->destco.'</span>' : $this->travel_country->destco;
if ($this->travel_country) {
return $badge ? '<span class="badge badge-secondary">' . $this->travel_country->destco . '</span>' : $this->travel_country->destco;
}
return "-";
}
public function countLeadMailsBy($dir, $subdir=false){
if($dir === 11){
return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count();
}
if($subdir){
return $this->lead_mails->where('dir', $dir)->where('subdir', $subdir)->count();
}
return $this->lead_mails->where('dir', $dir)->count();
}
public function countLeadMailsBy($dir, $subdir = false)
{
if ($dir === 11) {
return $this->lead_mails->where('draft', true)->where('dir', '!=', 12)->count();
}
if ($subdir) {
return $this->lead_mails->where('dir', $dir)->where('subdir', $subdir)->count();
}
return $this->lead_mails->where('dir', $dir)->count();
}
public function getPassolutionPDF($create = false, $resync = false){
public function getPassolutionPDF($create = false, $resync = false)
{
$nats = [];
$nats = [];
if(count($this->passolutionPDFs)){
return $this->passolutionPDFs;
}
if (count($this->passolutionPDFs)) {
return $this->passolutionPDFs;
}
if(!$this->travel_country){
return $this->passolutionPDFs;
}
$destco = $this->travel_country->destco;
if (!$this->travel_country) {
return $this->passolutionPDFs;
}
$destco = $this->travel_country->destco;
//default no travel_nationality
$nats['de'] = 'de';
if($this->lead_participants->count()){
foreach ($this->lead_participants as $participant){
if($participant->travel_nationality){
$nats[$participant->travel_nationality->nat] = $participant->travel_nationality->nat;
}
}
}
if(empty($nats)){
$nats['de'] = 'de';
}
foreach ($nats as $nat){
$data = [
'nat' => $nat,
'destco' => $destco,
];
$passolution = new Passolution($data);
$this->passolutionPDFs[] = $passolution->findOrCreatePDF($create, $resync);
}
return $this->passolutionPDFs;
}
public function resyncPassolutionPDF(){
return $this->getPassolutionPDF(true, true);
}
if ($this->lead_participants->count()) {
foreach ($this->lead_participants as $participant) {
if ($participant->travel_nationality) {
$nats[$participant->travel_nationality->nat] = $participant->travel_nationality->nat;
}
}
}
if (empty($nats)) {
$nats['de'] = 'de';
}
foreach ($nats as $nat) {
$data = [
'nat' => $nat,
'destco' => $destco,
];
$passolution = new Passolution($data);
$this->passolutionPDFs[] = $passolution->findOrCreatePDF($create, $resync);
}
return $this->passolutionPDFs;
}
public function resyncPassolutionPDF()
{
return $this->getPassolutionPDF(true, true);
}
}

View file

@ -1,56 +1,132 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Offer
* Angebot (Modul 6).
*
* 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).
*
* @property int $id
* @property int $lead_id
* @property float $total
* @property boolean $binary_data
* @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 Lead $lead
* @package App\Models
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer query()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereBinaryData($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereLeadId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereTotal($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Offer whereUpdatedAt($value)
* @mixin \Eloquent
* @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-read Collection|OfferVersion[] $versions
* @property-read Collection|OfferAccessToken[] $accessTokens
* @property-read User $creator
*/
class Offer extends Model
{
protected $connection = 'mysql';
use HasFactory, SoftDeletes;
protected $table = 'offer';
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_EXPIRED = 'expired';
public const STATUS_WITHDRAWN = 'withdrawn';
protected $casts = [
'lead_id' => 'int',
'total' => 'float',
'binary_data' => 'boolean'
];
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_SENT,
self::STATUS_ACCEPTED,
self::STATUS_DECLINED,
self::STATUS_EXPIRED,
self::STATUS_WITHDRAWN,
];
protected $fillable = [
'lead_id',
'total',
'binary_data'
];
protected $table = 'offers';
public function lead()
{
return $this->belongsTo(Lead::class);
}
protected $fillable = [
'offer_number',
'contact_id',
'inquiry_id',
'booking_id',
'status',
'current_version_id',
'created_by',
];
protected $casts = [
'contact_id' => 'int',
'inquiry_id' => 'int',
'booking_id' => 'int',
'current_version_id' => 'int',
'created_by' => 'int',
];
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
}
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);
}
public function currentVersion(): BelongsTo
{
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
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeStatus(Builder $q, string $status): Builder
{
return $q->where('status', $status);
}
public function scopeOpen(Builder $q): Builder
{
return $q->whereIn('status', [self::STATUS_DRAFT, self::STATUS_SENT]);
}
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SENT], true);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / 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.
*
* 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 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
*/
class OfferAccessToken extends Model
{
use HasFactory;
protected $table = 'offer_access_tokens';
protected $fillable = [
'offer_id',
'offer_version_id',
'token_hash',
'expires_at',
'first_opened_at',
'revoked_at',
];
protected $casts = [
'offer_id' => 'int',
'offer_version_id' => 'int',
'expires_at' => 'datetime',
'first_opened_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
}
public function version(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function scopeActive(Builder $q): Builder
{
return $q->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);
/** @var self $token */
$token = self::create([
'offer_id' => $offer->id,
'offer_version_id' => $version->id,
'token_hash' => $hash,
'expires_at' => $expiresAt,
]);
return ['plain' => $plain, 'token' => $token];
}
/**
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
*/
public static function findByPlainToken(string $plain): ?self
{
return self::where('token_hash', hash('sha256', $plain))->first();
}
public function isActive(): bool
{
if ($this->revoked_at !== null) {
return false;
}
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return false;
}
return true;
}
}

99
app/Models/OfferFile.php Normal file
View file

@ -0,0 +1,99 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Datei-Anhang einer Angebotsversion (Modul 6).
*
* 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.
*
* @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
*/
class OfferFile extends Model
{
use HasFactory;
protected $table = 'offer_files';
protected $fillable = [
'offer_version_id',
'identifier',
'filename',
'dir',
'original_name',
'ext',
'mine',
'size',
'include_in_pdf',
];
protected $casts = [
'offer_version_id' => 'int',
'size' => 'int',
'include_in_pdf' => 'bool',
];
public static array $iconExt = [
'default' => 'fa fa-file',
'pdf' => 'fa fa-file-pdf',
'jpg' => 'fa fa-file-image',
'jpeg' => 'fa fa-file-image',
'png' => 'fa fa-file-image',
'doc' => 'fa fa-file-word',
'docx' => 'fa fa-file-word',
];
public function version(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
public function getIconExt(): string
{
return self::$iconExt[$this->ext] ?? self::$iconExt['default'];
}
public function getURL(bool|string $do = false): string
{
return route('storage_file', [$this->id, 'offer', $do]);
}
public function getPath(): string
{
return \Storage::disk('offer')->path($this->dir . $this->filename);
}
public function formatBytes(int $precision = 2): string
{
$size = $this->size;
if ($size <= 0) {
return (string) $size;
}
$base = log($size) / log(1024);
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
return round(1024 ** ($base - floor($base)), $precision) . $suffixes[floor($base)];
}
}

86
app/Models/OfferItem.php Normal file
View file

@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Position einer Angebotsversion (Modul 6).
*
* `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.
*
* @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
*/
class OfferItem extends Model
{
use HasFactory;
public const TYPE_TRAVEL = 'travel';
public const TYPE_SERVICE = 'service';
public const TYPE_OPTION = 'option';
public const TYPE_DISCOUNT = 'discount';
public const TYPE_INSURANCE = 'insurance';
public const TYPE_CUSTOM = 'custom';
protected $table = 'offer_items';
protected $fillable = [
'offer_version_id',
'position',
'type',
'title',
'description',
'quantity',
'price_per_unit',
'total_price',
'travel_program_id',
'fewo_lodging_id',
'metadata',
];
protected $casts = [
'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',
'metadata' => 'array',
];
public function version(): 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).
*/
public function calculateTotal(): float
{
return round($this->quantity * (float) $this->price_per_unit, 2);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Wiederverwendbare Angebots-Vorlage (Modul 6).
*
* 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-read Branch|null $branch
* @property-read User $creator
*/
class OfferTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'offer_templates';
protected $fillable = [
'branch_id',
'name',
'description',
'default_headline',
'default_intro',
'default_itinerary',
'default_closing',
'default_items',
'is_active',
'created_by',
];
protected $casts = [
'branch_id' => 'int',
'default_items' => 'array',
'is_active' => 'bool',
'created_by' => 'int',
];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeActive(Builder $q): Builder
{
return $q->where('is_active', true);
}
}

126
app/Models/OfferVersion.php Normal file
View file

@ -0,0 +1,126 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Version eines Angebots (Modul 6).
*
* 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).
*
* @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
*/
class OfferVersion extends Model
{
use HasFactory;
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUPERSEDED = 'superseded';
public const ACCEPTED_VIA_LINK = 'customer_link';
public const ACCEPTED_VIA_ADMIN = 'admin';
public const ACCEPTED_VIA_MAIL = 'email';
protected $table = 'offer_versions';
protected $fillable = [
'offer_id',
'version_no',
'status',
'valid_until',
'total_price',
'headline',
'intro_text',
'itinerary_text',
'closing_text',
'template_id',
'pdf_path',
'pdf_archived',
'sent_at',
'accepted_at',
'accepted_via',
'template_document_ids',
'created_by',
];
protected $casts = [
'offer_id' => 'int',
'version_no' => 'int',
'valid_until' => 'date',
'total_price' => 'decimal:2',
'template_id' => 'int',
'pdf_archived' => 'bool',
'sent_at' => 'datetime',
'accepted_at' => 'datetime',
'template_document_ids' => 'array',
'created_by' => 'int',
];
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class);
}
public function template(): BelongsTo
{
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
{
return $this->belongsTo(User::class, 'created_by');
}
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
}