WIP: Sicherheitsnetz vor Phase-1-R\u00fcckbau

Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands,
Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries')
+ Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php).

Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/
verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist
und direkt auf Live deploybar wird.

Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz

Made-with: Cursor
This commit is contained in:
Phase-1-Rollback-Agent 2026-04-17 13:40:31 +00:00
parent 389d5d1820
commit e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions

View file

@ -32,7 +32,10 @@ class Account extends Model
use SoftDeletes;
protected $dates = ['deleted_at'];
protected $casts = [
'deleted_at' => 'datetime',
];
public function user()

View file

@ -55,14 +55,12 @@ class Arrangement extends Model
'view_position' => 'int',
'booking_id' => 'int',
'type_id' => 'int',
'in_pdf' => 'bool'
'in_pdf' => 'bool',
'state' => 'datetime',
'begin' => 'datetime',
'end' => 'datetime',
];
protected $dates = [
'state',
'begin',
'end'
];
protected $fillable = [
'template_id',

View file

@ -9,6 +9,7 @@ namespace App\Models;
use Carbon\Carbon;
use App\Services\Util;
use App\Services\Passolution;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
@ -18,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
@ -203,13 +204,16 @@ use Illuminate\Database\Eloquent\Collection;
*/
class Booking extends Model
{
use HasFactory;
protected $connection = 'mysql';
protected $table = 'booking';
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',
@ -237,25 +241,24 @@ class Booking extends Model
'is_rail_fly' => 'bool',
'comfort' => 'bool',
'airline_ids' => 'array',
'participant_pass' => 'bool'
];
'participant_pass' => 'bool',
'booking_date' => 'datetime',
'start_date' => 'datetime',
'end_date' => 'datetime',
'participant_birthdate' => 'datetime',
'final_payment_date' => 'datetime',
'refund_date' => 'datetime',
'lawyer_date' => 'datetime',
'xx_tkt_date' => 'datetime',
];
protected $dates = [
'booking_date',
'start_date',
'end_date',
'participant_birthdate',
'final_payment_date',
'refund_date',
'lawyer_date',
'xx_tkt_date'
];
protected $fillable = [
'booking_date',
'customer_id',
'lead_id',
'inquiry_id',
'offer_id',
'new_drafts',
'sf_guard_user_id',
'branch_id',
@ -392,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()

View file

@ -48,12 +48,10 @@ class BookingConfirmation extends Model
'total' => 'float',
'deposit' => 'float',
'final_payment' => 'float',
'deposit_payment_date' => 'datetime',
'final_payment_date' => 'datetime',
];
protected $dates = [
'deposit_payment_date',
'final_payment_date'
];
protected $fillable = [
'booking_id',

View file

@ -71,11 +71,9 @@ class BookingDocument extends Model
'status' => 'int',
'booking_storno_id' => 'int',
'coupon_id' => 'int',
'date' => 'datetime',
];
protected $dates = [
'date'
];
protected $fillable = [
'booking_id',

View file

@ -48,12 +48,10 @@ class BookingInvoice extends Model
'total' => 'float',
'deposit' => 'float',
'final_payment' => 'float',
'deposit_payment_date' => 'datetime',
'final_payment_date' => 'datetime',
];
protected $dates = [
'deposit_payment_date',
'final_payment_date'
];
protected $fillable = [
'booking_id',

View file

@ -52,12 +52,10 @@ class BookingNotice extends Model
'from_user_id' => 'int',
'to_user_id' => 'int',
'show' => 'bool',
'important' => 'bool'
'important' => 'bool',
'edit_at' => 'datetime',
];
protected $dates = [
'edit_at',
];
protected $fillable = [
'booking_id',

View file

@ -55,12 +55,10 @@ class BookingServiceItem extends Model
'service_price' => 'float',
'service_price_refund' => 'float',
'commission' => 'float',
'is_commission_locked' => 'bool'
'is_commission_locked' => 'bool',
'travel_date' => 'datetime',
];
protected $dates = [
'travel_date'
];
protected $fillable = [
'booking_id',

View file

@ -49,13 +49,11 @@ class BookingStorno extends Model
'booking_id' => 'int',
'total' => 'float',
'storno' => 'float',
'done' => 'bool'
'done' => 'bool',
'storno_date' => 'datetime',
'storno_print' => 'datetime',
];
protected $dates = [
'storno_date',
'storno_print'
];
protected $fillable = [
'booking_id',

164
app/Models/Contact.php Normal file
View file

@ -0,0 +1,164 @@
<?php
namespace App\Models;
use App\Models\Sym\TravelCountry;
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\SoftDeletes;
/**
* Kontakt-Modell saubere Neuimplementierung auf Basis der customer-Tabelle.
*
* Unterschiede zum alten Customer-Modell:
* - Global Scope schließt zusammengeführte Duplikate (merged_into_id IS NOT NULL) aus
* - merged_into_id + merged_at in $fillable
* - mergedInto() / mergedContacts() Beziehungen
*
* Tabellen-Name: 'contacts' (nach Phase 2 RENAME TABLE customer contacts).
*
* @property int $id
* @property int|null $salutation_id
* @property string|null $title
* @property string|null $name
* @property string|null $firstname
* @property Carbon|null $birthdate
* @property string|null $company
* @property string|null $street
* @property string|null $zip
* @property string|null $city
* @property string|null $email
* @property string|null $phone
* @property string|null $phonebusiness
* @property string|null $phonemobile
* @property string|null $fax
* @property int|null $merged_into_id
* @property Carbon|null $merged_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Contact|null $mergedInto
* @property-read Collection|Contact[] $mergedContacts
* @property-read Collection|Lead[] $leads
* @property-read Collection|Booking[] $bookings
*/
class Contact extends Model
{
use HasFactory, SoftDeletes;
protected $connection = 'mysql';
protected $table = 'contacts';
protected $casts = [
'salutation_id' => 'int',
'credit_card_type_id' => 'int',
'country_id' => 'int',
'merged_into_id' => 'int',
'birthdate' => 'datetime',
'credit_card_expiration_date' => 'datetime',
'merged_at' => 'datetime',
];
protected $fillable = [
'salutation_id',
'title',
'name',
'firstname',
'birthdate',
'company',
'street',
'zip',
'city',
'email',
'phone',
'phonebusiness',
'phonemobile',
'fax',
'bank',
'bank_code',
'bank_account_number',
'credit_card_type_id',
'credit_card_number',
'credit_card_expiration_date',
'participants_remarks',
'miscellaneous_remarks',
'country_id',
'merged_into_id',
'merged_at',
];
/**
* Globaler Scope: zusammengeführte Duplikate werden standardmäßig ausgeblendet.
* Für Zugriff auf alle inkl. Duplikate: Contact::withoutGlobalScope('not_merged')
*/
protected static function booted(): void
{
static::addGlobalScope('not_merged', function (Builder $query) {
$query->whereNull('merged_into_id');
});
}
// ── Beziehungen ──────────────────────────────────────────────────────────
public function mergedInto(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Contact::class, 'merged_into_id')
->withoutGlobalScope('not_merged');
}
public function mergedContacts(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Contact::class, 'merged_into_id')
->withoutGlobalScope('not_merged');
}
public function leads(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Lead::class, 'customer_id')->orderByDesc('created_at');
}
public function bookings(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Booking::class, 'customer_id')->orderByDesc('created_at');
}
public function salutation(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Salutation::class);
}
public function travel_country(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(TravelCountry::class, 'country_id');
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
public function fullName(): string
{
if ($this->firstname) {
return $this->firstname . ' ' . $this->name;
}
return (string) $this->name;
}
public function isMerged(): bool
{
return $this->merged_into_id !== null;
}
public static function getCountriesArray(): \Illuminate\Support\Collection
{
return TravelCountry::where('is_customer_country', 1)->get()->pluck('name', 'id');
}
public static $salutationType = [
1 => 'Herr',
2 => 'Frau',
3 => 'Divers/keine Anrede',
4 => 'Firma',
];
}

View file

@ -59,14 +59,12 @@ class Coupon extends Model
'customer_id' => 'int',
'booking_id' => 'int',
'value' => 'float',
'is_redeemed' => 'bool'
'is_redeemed' => 'bool',
'issue_date' => 'datetime',
'valid_date' => 'datetime',
'redeem_date' => 'datetime',
];
protected $dates = [
'issue_date',
'valid_date',
'redeem_date'
];
protected $fillable = [
'number',

View file

@ -9,6 +9,7 @@ namespace App\Models;
use App\Models\Sym\TravelCountry;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
@ -83,20 +84,26 @@ use Illuminate\Database\Eloquent\Model;
*/
class Customer extends Model
{
use HasFactory;
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',
'credit_card_type_id' => 'int',
'country_id' => 'int'
];
'country_id' => 'int',
'birthdate' => 'datetime',
'credit_card_expiration_date' => 'datetime',
];
protected $dates = [
'birthdate',
'credit_card_expiration_date'
];
protected $fillable = [
'salutation_id',

View file

@ -60,10 +60,11 @@ class CustomerFewoFile extends Model
protected $casts = [
'travel_user_id' => 'int',
'customer_fewo_mail_id' => 'int',
'size' => 'int'
'size' => 'int',
'deleted_at' => 'datetime',
];
protected $dates = ['deleted_at'];
protected $fillable = [
'travel_user_id',

View file

@ -106,15 +106,13 @@ class CustomerFewoMail extends Model
'recipient' => 'array',
'forward' => 'array',
'cc' => 'array',
'bcc' => 'array'
'bcc' => 'array',
'sent_at' => 'datetime',
'scheduled_at' => 'datetime',
'delivered_at' => 'datetime',
'deleted_at' => 'datetime',
];
protected $dates = [
'sent_at',
'scheduled_at',
'delivered_at',
'deleted_at'
];
protected $fillable = [
'travel_user_booking_fewo_id',

View file

@ -109,14 +109,12 @@ class CustomerMail extends Model
'recipient' => 'array',
'forward' => 'array',
'cc' => 'array',
'bcc' => 'array'
'bcc' => 'array',
'sent_at' => 'datetime',
'scheduled_at' => 'datetime',
'delivered_at' => 'datetime',
];
protected $dates = [
'sent_at',
'scheduled_at',
'delivered_at'
];
protected $fillable = [
'booking_id',

View file

@ -41,13 +41,11 @@ class FewoReservation extends Model
protected $casts = [
'lodging_id' => 'int',
'status' => 'int',
'type' => 'int'
'type' => 'int',
'from_date' => 'datetime',
'to_date' => 'datetime',
];
protected $dates = [
'from_date',
'to_date'
];
protected $fillable = [
'lodging_id',

View file

@ -43,13 +43,11 @@ class FewoSeason extends Model
protected $casts = [
'minimum_stay' => 'int',
'only_weekday' => 'int'
'only_weekday' => 'int',
'from_date' => 'datetime',
'to_date' => 'datetime',
];
protected $dates = [
'from_date',
'to_date'
];
protected $fillable = [
'name',

View file

@ -17,7 +17,7 @@ use Illuminate\Database\Eloquent\Model;
* @property int $tag_id
* @property Carbon $created_at
* @property Carbon $updated_at
*
*
* //* @property IQContentFile $i_q_content_file
* @property IQContentTag $i_q_content_tag
* @package App\Models

View file

@ -57,7 +57,7 @@ class IQContentTree extends Model
protected $connection = 'mysql_stern';
protected $dates = ['deleted_at'];
protected $table = 'i_q_content_trees';
@ -65,7 +65,9 @@ class IQContentTree extends Model
'name', 'identifier', 'description', 'settings', 'pos', 'active',
];
protected $casts = ['settings' => 'array'];
protected $casts = ['settings' => 'array',
'deleted_at' => 'datetime',
];
public function sluggable(): array
{

View file

@ -72,7 +72,7 @@ class IQContentTreeNode extends Model
protected $connection = 'mysql_stern';
protected $dates = ['deleted_at'];
protected $table = 'i_q_content_tree_nodes';
@ -80,7 +80,9 @@ class IQContentTreeNode extends Model
'tree_id', 'parent_id', 'lvl', 'name', 'identifier', 'title', 'description', 'settings', 'image', 'pos', 'active',
];
protected $casts = ['settings' => 'array', 'image' => 'array'];
protected $casts = ['settings' => 'array', 'image' => 'array',
'deleted_at' => 'datetime',
];
public function sluggable(): array
{

View file

@ -53,13 +53,11 @@ class Inquiry extends Model
'template_id' => 'int',
'in_pdf' => 'bool',
'type_id' => 'int',
'view_position' => 'int'
'view_position' => 'int',
'begin' => 'datetime',
'end' => 'datetime',
];
protected $dates = [
'begin',
'end'
];
protected $fillable = [
'lead_id',

View file

@ -9,6 +9,7 @@ namespace App\Models;
use Carbon\Carbon;
use App\Services\Passolution;
use App\Models\Lead as ModelsLead;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
@ -107,9 +108,16 @@ use Illuminate\Database\Eloquent\Collection;
*/
class Lead extends Model
{
use HasFactory;
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',
@ -126,16 +134,14 @@ class Lead extends Model
'travelcategory_id' => 'int',
'price' => 'float',
'pax' => 'int',
'participant_salutation_id' => 'int'
'participant_salutation_id' => 'int',
'request_date' => 'datetime',
'travelperiod_start' => 'datetime',
'travelperiod_end' => 'datetime',
'next_due_date' => 'datetime',
'participant_birthdate' => 'datetime',
];
protected $dates = [
'request_date',
'travelperiod_start',
'travelperiod_end',
'next_due_date',
'participant_birthdate'
];
protected $fillable = [
'customer_id',

View file

@ -91,14 +91,12 @@ class LeadMail extends Model
'recipient' => 'array',
'forward' => 'array',
'cc' => 'array',
'bcc' => 'array'
'bcc' => 'array',
'sent_at' => 'datetime',
'scheduled_at' => 'datetime',
'delivered_at' => 'datetime',
];
protected $dates = [
'sent_at',
'scheduled_at',
'delivered_at'
];
protected $fillable = [
'lead_id',

View file

@ -53,12 +53,10 @@ class LeadNotice extends Model
'from_user_id' => 'int',
'to_user_id' => 'int',
'show' => 'bool',
'important' => 'bool'
'important' => 'bool',
'edit_at' => 'datetime',
];
protected $dates = [
'edit_at'
];
protected $fillable = [
'lead_id',

View file

@ -46,12 +46,10 @@ class LeadParticipant extends Model
protected $casts = [
'lead_id' => 'int',
'participant_salutation_id' => 'int'
'participant_salutation_id' => 'int',
'participant_birthdate' => 'datetime',
];
protected $dates = [
'participant_birthdate'
];
protected $fillable = [
'lead_id',

View file

@ -36,6 +36,49 @@ use Illuminate\Database\Eloquent\Collection;
* @property-read TravelUser|null $travel_user
* @property-read Collection|NewsletterLog[] $logs
* @package App\Models
* @property-read mixed $full_name
* @property-read mixed $groups
* @property-read mixed $groups_string
* @property-read mixed $source_label
* @property-read mixed $status_badge
* @property-read mixed $status_color
* @property-read mixed $status_label
* @property-read mixed $total_bookings
* @property-read int|null $logs_count
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact active()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact ferienwohnungen()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact kulturreisen()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact multipleBookers()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact query()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereCustomerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereFirstname($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereGroupFerienwohnungen($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereGroupKulturreisen($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereLastBookingAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereLastSyncedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereLastTravelEndDate($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereLastname($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereNotes($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereSource($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereSubscribedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereSyncHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereTotalBookingsFerienwohnungen($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereTotalBookingsKulturreisen($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereTravelUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereUnsubscribedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact withBookings()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterContact withoutTrashed()
* @mixin \Eloquent
*/
class NewsletterContact extends Model
{

View file

@ -19,6 +19,19 @@ use Illuminate\Database\Eloquent\Model;
* @property-read NewsletterContact $newsletter_contact
* @property-read SfGuardUser|null $user
* @package App\Models
* @property-read mixed $action_label
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog query()
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereAction($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereMetadata($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereNewsletterContactId($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|NewsletterLog whereUserId($value)
* @mixin \Eloquent
*/
class NewsletterLog extends Model
{

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

View file

@ -152,12 +152,10 @@ class Page extends Model
'tree_root' => 'int',
'parent_id' => 'int',
'travel_guide_content_id' => 'int',
'fewo_lodging' => 'int'
'fewo_lodging' => 'int',
'date' => 'datetime',
];
protected $dates = [
'date'
];
protected $fillable = [
'owner',

View file

@ -53,13 +53,10 @@ class Participant extends Model
'participant_salutation_id' => 'int',
'participant_child' => 'bool',
'participant_pass' => 'bool',
'participant_storno' => 'bool'
'participant_storno' => 'bool',
'participant_birthdate' => 'datetime',
];
protected $dates = [
'participant_birthdate'
];
protected $fillable = [
'booking_id',

View file

@ -55,12 +55,10 @@ class ServiceProviderEntry extends Model
'amount' => 'float',
'amount_eur' => 'float',
'factor' => 'float',
'is_cleared' => 'bool'
'is_cleared' => 'bool',
'payment_date' => 'datetime',
];
protected $dates = [
'payment_date'
];
protected $fillable = [
'booking_id',

View file

@ -47,14 +47,12 @@ class StatusHistory extends Model
protected $casts = [
'status_id' => 'int',
'lead_id' => 'int',
'sf_guard_user_id' => 'int'
'sf_guard_user_id' => 'int',
'date' => 'datetime',
'target_date' => 'datetime',
'created_at' => 'datetime',
];
protected $dates = [
'date',
'target_date',
'created_at'
];
protected $fillable = [
'status_id',

View file

@ -137,15 +137,13 @@ class TravelBooking extends Model
'options' => 'array',
'class_options' => 'array',
'extra_category' => 'array',
'insurances' => 'array'
'insurances' => 'array',
'created' => 'datetime',
'selected_start_date' => 'datetime',
'selected_end_date' => 'datetime',
'final_payment_date' => 'datetime',
];
protected $dates = [
'created',
'selected_start_date',
'selected_end_date',
'final_payment_date'
];
protected $fillable = [
'crm_booking_id',

View file

@ -79,12 +79,10 @@ class TravelUser extends Model
protected $casts = [
'salutation_id' => 'int',
'travel_nationality_id' => 'int',
'last_user_data' => 'array'
'last_user_data' => 'array',
'birthday' => 'datetime',
];
protected $dates = [
'birthday'
];
protected $hidden = [
'password'

View file

@ -159,14 +159,12 @@ class TravelUserBookingFewo extends Model
'send_service_mail' => 'array',
'send_info_mail' => 'array',
'send_employee_mail' => 'array',
'booking_date' => 'datetime',
'from_date' => 'datetime',
'to_date' => 'datetime',
'deleted_at' => 'datetime',
];
protected $dates = [
'booking_date',
'from_date',
'to_date',
'deleted_at'
];
protected $fillable = [
'travel_user_id',
@ -545,7 +543,7 @@ class TravelUserBookingFewo extends Model
if(!Storage::disk('fewo_invoices')->exists( $dir )){
Storage::disk('fewo_invoices')->makeDirectory($dir); //creates directory
}
$path = Storage::disk('fewo_invoices')->getAdapter()->getPathPrefix();
$path = Storage::disk('fewo_invoices')->path('');
return $path.$dir;
}
@ -621,7 +619,7 @@ class TravelUserBookingFewo extends Model
if(!Storage::disk('fewo_infos')->exists( $dir )){
Storage::disk('fewo_infos')->makeDirectory($dir); //creates directory
}
$path = Storage::disk('fewo_infos')->getAdapter()->getPathPrefix();
$path = Storage::disk('fewo_infos')->path('');
return $path.$dir;
}

View file

@ -53,12 +53,10 @@ class TravelUserBookingFewoNotice extends Model
'from_user_id' => 'int',
'to_user_id' => 'int',
'show' => 'bool',
'important' => 'bool'
'important' => 'bool',
'edit_at' => 'datetime',
];
protected $dates = [
'edit_at'
];
protected $fillable = [
'travel_user_booking_fewo_id',