deplay phase 1
This commit is contained in:
parent
e3dc1afd8e
commit
5a7478907e
68 changed files with 2831 additions and 818 deletions
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 1: customer → contacts
|
||||
*
|
||||
* Benennt die Tabelle um. Alle Foreign Keys, die auf `customer` zeigen,
|
||||
* werden von MySQL automatisch mitgezogen (ON DELETE / ON UPDATE bleiben).
|
||||
*
|
||||
* Rollback: benennt `contacts` zurück in `customer`.
|
||||
*
|
||||
* HINWEIS: Vor dem Deployment sicherstellen, dass die App-Models
|
||||
* bereits $table = 'contacts' verwenden, ODER die Migration vor dem
|
||||
* Code-Deployment ausführen und im Notfall zurückrollen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// MySQL: RENAME TABLE ist ein atomarer DDL-Befehl
|
||||
DB::statement('RENAME TABLE `customer` TO `contacts`');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `contacts` TO `customer`');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 2: lead → inquiries
|
||||
*
|
||||
* Benennt die Tabelle um. Abhängige Tabellen:
|
||||
* - lead_mails (lead_id FK → wird mitgezogen)
|
||||
* - lead_files (lead_id FK → wird mitgezogen)
|
||||
* - lead_notices (lead_id FK → wird mitgezogen)
|
||||
* - lead_participant (lead_id FK → wird mitgezogen)
|
||||
* - booking (lead_id FK → wird mitgezogen)
|
||||
* - customer_mails (lead_id FK → wird mitgezogen)
|
||||
*
|
||||
* Rollback: benennt `inquiries` zurück in `lead`.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `lead` TO `inquiries`');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('RENAME TABLE `inquiries` TO `lead`');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 2 — Schritt 3: booking.lead_id → booking.inquiry_id
|
||||
*
|
||||
* Benennt die Spalte und den Foreign Key um.
|
||||
* inquiry_id bleibt nullable (Direktbuchungen ohne Anfrage sind erlaubt).
|
||||
*
|
||||
* Rollback: benennt inquiry_id zurück in lead_id.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Foreign Key muss zuerst gedroppt werden, bevor die Spalte umbenannt wird
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
// Drop existing FK (Name aus DB-Schema)
|
||||
$table->dropForeign('booking_lead_id_lead_id');
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE `booking` RENAME COLUMN `lead_id` TO `inquiry_id`');
|
||||
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
// Neuen FK auf umbenannte Tabelle setzen
|
||||
$table->foreign('inquiry_id', 'booking_inquiry_id_inquiries_id')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('no action')
|
||||
->onUpdate('no action');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
$table->dropForeign('booking_inquiry_id_inquiries_id');
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE `booking` RENAME COLUMN `inquiry_id` TO `lead_id`');
|
||||
|
||||
Schema::table('booking', function (Blueprint $table) {
|
||||
$table->foreign('lead_id', 'booking_lead_id_lead_id')
|
||||
->references('id')->on('lead')
|
||||
->onDelete('no action')
|
||||
->onUpdate('no action');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — Schritt 1: Neue participants-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_participant + participant in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 3 Schritt 2
|
||||
* nach Abschluss der Tests gedroppt).
|
||||
*
|
||||
* Neue Felder:
|
||||
* - inquiry_id : FK auf inquiries (war: lead_participant.lead_id)
|
||||
* - booking_id : FK auf booking (war: participant.booking_id)
|
||||
* - participant_pass : nur bei Buchungs-Teilnehmern relevant
|
||||
* - participant_storno: nur bei Buchungs-Teilnehmern relevant
|
||||
* - is_lead_contact : markiert den Hauptkontakt aus lead.participant_name
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('participants_unified', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — genau einer ist gesetzt, der andere NULL
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Teilnehmerdaten
|
||||
$table->string('participant_name')->nullable();
|
||||
$table->string('participant_firstname')->nullable();
|
||||
$table->date('participant_birthdate')->nullable();
|
||||
$table->unsignedBigInteger('participant_salutation_id')->nullable();
|
||||
$table->boolean('participant_child')->default(false);
|
||||
$table->integer('nationality_id')->nullable();
|
||||
|
||||
// Nur bei Buchungs-Teilnehmern
|
||||
$table->boolean('participant_pass')->default(false);
|
||||
$table->boolean('participant_storno')->default(false);
|
||||
|
||||
// Markiert den Hauptreisenden aus lead.participant_name
|
||||
$table->boolean('is_lead_contact')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
|
||||
$table->foreign('inquiry_id', 'pu_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'pu_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('participant_salutation_id', 'pu_salutation_id_fk')
|
||||
->references('id')->on('salutation')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_participant → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, 0,
|
||||
NOW(), NOW()
|
||||
FROM lead_participant
|
||||
");
|
||||
|
||||
// 2. Aus lead.participant_name → Hauptreisende-Datensätze (is_lead_contact = 1)
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, 0,
|
||||
NULL, 1,
|
||||
NOW(), NOW()
|
||||
FROM inquiries
|
||||
WHERE participant_name IS NOT NULL
|
||||
AND participant_name != ''
|
||||
");
|
||||
|
||||
// 3. Aus participant → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO participants_unified
|
||||
(inquiry_id, booking_id, participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, participant_pass, participant_storno,
|
||||
is_lead_contact, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id,
|
||||
participant_name, participant_firstname,
|
||||
participant_birthdate, participant_salutation_id, participant_child,
|
||||
nationality_id, participant_pass, participant_storno,
|
||||
0, NOW(), NOW()
|
||||
FROM participant
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('participants_unified');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 3 — Schritt 2: Alte Participant-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 3 Schritt 1 (participants_unified) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf participants_unified umgestellt
|
||||
* 3. Datenmigration durch Vergleich der Zeilenzahlen geprüft:
|
||||
* SELECT COUNT(*) FROM lead_participant;
|
||||
* SELECT COUNT(*) FROM participant;
|
||||
* SELECT COUNT(*) FROM participants_unified WHERE inquiry_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM participants_unified WHERE booking_id IS NOT NULL;
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup — erst ausführen wenn sicher!
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// FKs in lead_participant zeigen auf lead (jetzt inquiries) — werden mitgedroppt
|
||||
Schema::dropIfExists('lead_participant');
|
||||
|
||||
// FKs in participant zeigen auf booking — werden mitgedroppt
|
||||
Schema::dropIfExists('participant');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Bewusst leer: das Droppen von Produktionsdaten ist irreversibel.
|
||||
// Für einen vollständigen Rollback bitte Datenbank-Backup einspielen.
|
||||
throw new \RuntimeException(
|
||||
'Phase 3 Schritt 2 kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1a: communications-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_mails + customer_mails in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
|
||||
* (lead → inquiries, customer → contacts).
|
||||
*
|
||||
* Besonderheiten:
|
||||
* - reply_id ist selbst-referenziell; nach beiden INSERTs wird ein Remapping
|
||||
* der alten IDs auf die neuen IDs durchgeführt (via legacy_source + legacy_id).
|
||||
* - travel_country_id existiert nur in customer_mails → nullable, NULL für lead_mails.
|
||||
* - customer_mails hat sowohl lead_id als auch booking_id → beide werden übernommen.
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('communications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — bei customer_mails können beide gesetzt sein
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
$table->unsignedBigInteger('contact_id')->nullable();
|
||||
|
||||
// Reply-Chain — kein FK-Constraint (cross-table Remapping)
|
||||
$table->unsignedBigInteger('reply_id')->nullable();
|
||||
|
||||
// Herkunft für Remapping und spätere Analyse
|
||||
$table->enum('legacy_source', ['lead_mail', 'customer_mail']);
|
||||
$table->unsignedBigInteger('legacy_id');
|
||||
|
||||
// Mail-Felder (identisch in beiden Quell-Tabellen)
|
||||
$table->boolean('is_answer')->default(false);
|
||||
$table->string('email')->nullable();
|
||||
$table->text('recipient')->nullable();
|
||||
$table->text('cc')->nullable();
|
||||
$table->text('bcc')->nullable();
|
||||
$table->string('subject')->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->boolean('dir')->default(false);
|
||||
$table->unsignedBigInteger('subdir')->nullable();
|
||||
|
||||
// Nur in customer_mails vorhanden
|
||||
$table->unsignedBigInteger('travel_country_id')->nullable();
|
||||
|
||||
$table->boolean('draft')->default(false);
|
||||
$table->boolean('important')->default(false);
|
||||
$table->boolean('send')->default(false);
|
||||
$table->boolean('fail')->default(false);
|
||||
$table->text('error')->nullable();
|
||||
$table->text('forward')->nullable();
|
||||
|
||||
$table->dateTime('sent_at')->nullable();
|
||||
$table->dateTime('scheduled_at')->nullable();
|
||||
$table->dateTime('delivered_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('contact_id');
|
||||
$table->index('reply_id');
|
||||
$table->index(['legacy_source', 'legacy_id'], 'comm_legacy_idx');
|
||||
|
||||
$table->foreign('inquiry_id', 'comm_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'comm_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('contact_id', 'comm_contact_id_fk')
|
||||
->references('id')->on('contacts')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_mails → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO communications
|
||||
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
|
||||
legacy_source, legacy_id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, customer_id, is_answer, reply_id,
|
||||
'lead_mail', id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, NULL,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at
|
||||
FROM lead_mails
|
||||
");
|
||||
|
||||
// 2. Aus customer_mails → booking_id gesetzt (kann zusätzlich lead_id haben)
|
||||
DB::statement("
|
||||
INSERT INTO communications
|
||||
(inquiry_id, booking_id, contact_id, is_answer, reply_id,
|
||||
legacy_source, legacy_id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, booking_id, customer_id, is_answer, reply_id,
|
||||
'customer_mail', id,
|
||||
email, recipient, cc, bcc, subject, message,
|
||||
dir, subdir, travel_country_id,
|
||||
draft, important, send, fail, error, forward,
|
||||
sent_at, scheduled_at, delivered_at, created_at, updated_at
|
||||
FROM customer_mails
|
||||
");
|
||||
|
||||
// 3. Reply-IDs remappen: alte source-table-IDs auf neue communications-IDs umstellen
|
||||
// Funktioniert weil reply_id immer auf eine Mail derselben Quell-Tabelle zeigt.
|
||||
DB::statement("
|
||||
UPDATE communications c1
|
||||
INNER JOIN communications c2
|
||||
ON c1.legacy_source = c2.legacy_source
|
||||
AND c2.legacy_id = c1.reply_id
|
||||
SET c1.reply_id = c2.id
|
||||
WHERE c1.reply_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('communications');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1b: notices-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_notices + booking_notices in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 muss bereits ausgeführt worden sein
|
||||
* (lead → inquiries, customer → contacts).
|
||||
*
|
||||
* Struktur: identisch in beiden Quell-Tabellen — nur inquiry_id vs. booking_id.
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs — genau einer ist gesetzt
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Benutzer-Referenzen (integer wie in den Quell-Tabellen)
|
||||
$table->unsignedInteger('from_user_id');
|
||||
$table->unsignedInteger('to_user_id')->nullable();
|
||||
|
||||
$table->text('message')->nullable();
|
||||
$table->boolean('show')->default(false);
|
||||
$table->boolean('important')->default(false);
|
||||
$table->dateTime('edit_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('from_user_id');
|
||||
$table->index('to_user_id');
|
||||
|
||||
$table->foreign('inquiry_id', 'notices_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'notices_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('from_user_id', 'notices_from_user_id_fk')
|
||||
->references('id')->on('users')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('to_user_id', 'notices_to_user_id_fk')
|
||||
->references('id')->on('users')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_notices → inquiry_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO notices
|
||||
(inquiry_id, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at
|
||||
FROM lead_notices
|
||||
");
|
||||
|
||||
// 2. Aus booking_notices → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO notices
|
||||
(inquiry_id, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id, from_user_id, to_user_id,
|
||||
message, show, important, edit_at, created_at, updated_at
|
||||
FROM booking_notices
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notices');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 1c: attachments-Tabelle erstellen und Daten migrieren
|
||||
*
|
||||
* Konsolidiert lead_files + booking_files in eine einheitliche Tabelle.
|
||||
* Die alten Tabellen bleiben zunächst erhalten (werden in Phase 4 Schritt 2 gedroppt).
|
||||
*
|
||||
* Voraussetzung: Phase 2 + Phase 4a (communications) müssen bereits ausgeführt sein,
|
||||
* da communication_id auf die neue communications-Tabelle verweist.
|
||||
*
|
||||
* Besonderheiten:
|
||||
* - lead_files hat lead_mail_id (FK auf lead_mails) → wird auf communication_id gemappt
|
||||
* - booking_files hat keinen Mail-Bezug → communication_id bleibt NULL
|
||||
* - Spalte `mine` in den Quell-Tabellen ist ein Tippfehler für mime → hier: mime_type
|
||||
*
|
||||
* Rollback: droppt die neue Tabelle (alte Tabellen bleiben unverändert).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kontext-FKs
|
||||
$table->unsignedBigInteger('inquiry_id')->nullable();
|
||||
$table->unsignedBigInteger('booking_id')->nullable();
|
||||
|
||||
// Verknüpfung zur dazugehörigen Mail (nur bei Anfrage-Anhängen)
|
||||
$table->unsignedBigInteger('communication_id')->nullable();
|
||||
|
||||
// Datei-Metadaten
|
||||
$table->string('identifier')->nullable();
|
||||
$table->string('filename');
|
||||
$table->string('dir')->nullable();
|
||||
$table->string('original_name')->nullable();
|
||||
$table->string('ext')->nullable();
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedInteger('size')->nullable();
|
||||
|
||||
// Herkunft für spätere Analyse / Rollback
|
||||
$table->enum('legacy_source', ['lead_file', 'booking_file']);
|
||||
$table->unsignedBigInteger('legacy_id');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('inquiry_id');
|
||||
$table->index('booking_id');
|
||||
$table->index('communication_id');
|
||||
$table->index('identifier', 'attachments_identifier_idx');
|
||||
$table->index(['legacy_source', 'legacy_id'], 'attachments_legacy_idx');
|
||||
|
||||
$table->foreign('inquiry_id', 'att_inquiry_id_fk')
|
||||
->references('id')->on('inquiries')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('booking_id', 'att_booking_id_fk')
|
||||
->references('id')->on('booking')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('communication_id', 'att_communication_id_fk')
|
||||
->references('id')->on('communications')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// ── Daten migrieren ─────────────────────────────────────────────────
|
||||
|
||||
// 1. Aus lead_files → inquiry_id gesetzt; communication_id wird unten gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO attachments
|
||||
(inquiry_id, booking_id, communication_id,
|
||||
identifier, filename, dir, original_name, ext, mime_type, size,
|
||||
legacy_source, legacy_id, created_at, updated_at)
|
||||
SELECT
|
||||
lead_id, NULL, NULL,
|
||||
identifier, filename, dir, original_name, ext, mine, size,
|
||||
'lead_file', id, created_at, updated_at
|
||||
FROM lead_files
|
||||
");
|
||||
|
||||
// 2. Aus booking_files → booking_id gesetzt
|
||||
DB::statement("
|
||||
INSERT INTO attachments
|
||||
(inquiry_id, booking_id, communication_id,
|
||||
identifier, filename, dir, original_name, ext, mime_type, size,
|
||||
legacy_source, legacy_id, created_at, updated_at)
|
||||
SELECT
|
||||
NULL, booking_id, NULL,
|
||||
identifier, filename, dir, original_name, ext, mine, size,
|
||||
'booking_file', id, created_at, updated_at
|
||||
FROM booking_files
|
||||
");
|
||||
|
||||
// 3. communication_id für lead_files setzen (sofern lead_mail_id gesetzt war)
|
||||
// Nutzt legacy_source + legacy_id der communications-Tabelle.
|
||||
DB::statement("
|
||||
UPDATE attachments a
|
||||
INNER JOIN lead_files lf ON lf.id = a.legacy_id
|
||||
INNER JOIN communications c
|
||||
ON c.legacy_source = 'lead_mail'
|
||||
AND c.legacy_id = lf.lead_mail_id
|
||||
SET a.communication_id = c.id
|
||||
WHERE a.legacy_source = 'lead_file'
|
||||
AND lf.lead_mail_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('attachments');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2a: Alte Mail-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1a (communications) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf communications umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_mails;
|
||||
* SELECT COUNT(*) FROM customer_mails;
|
||||
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'lead_mail';
|
||||
* SELECT COUNT(*) FROM communications WHERE legacy_source = 'customer_mail';
|
||||
* 4. Reply-Chain korrekt: spot-check einiger Datensätze mit reply_id
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// lead_files referenziert lead_mails → erst lead_files droppen oder FK bereits weg
|
||||
// (lead_files wird in 400005 gedroppt — daher hier prüfen ob noch FK vorhanden)
|
||||
// Sicherheitshalber: lead_files.lead_mail_id FK zuerst entfernen falls noch vorhanden
|
||||
if (Schema::hasTable('lead_files') && Schema::hasColumn('lead_files', 'lead_mail_id')) {
|
||||
Schema::table('lead_files', function ($table) {
|
||||
$table->dropForeign('lead_files_lead_mail_id_foreign');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::dropIfExists('lead_mails');
|
||||
Schema::dropIfExists('customer_mails');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2a kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2b: Alte Notiz-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1b (notices) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf notices umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_notices;
|
||||
* SELECT COUNT(*) FROM booking_notices;
|
||||
* SELECT COUNT(*) FROM notices WHERE inquiry_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM notices WHERE booking_id IS NOT NULL;
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('lead_notices');
|
||||
Schema::dropIfExists('booking_notices');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2b kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Phase 4 — Schritt 2c: Alte Datei-Tabellen droppen
|
||||
*
|
||||
* Voraussetzungen (vor Ausführung prüfen!):
|
||||
* 1. Phase 4 Schritt 1c (attachments) läuft stabil in Produktion
|
||||
* 2. Alle Queries / Repositories auf attachments umgestellt
|
||||
* 3. Datenmigration geprüft:
|
||||
* SELECT COUNT(*) FROM lead_files;
|
||||
* SELECT COUNT(*) FROM booking_files;
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file';
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'booking_file';
|
||||
* 4. communication_id Verknüpfung geprüft:
|
||||
* SELECT COUNT(*) FROM lead_files WHERE lead_mail_id IS NOT NULL;
|
||||
* SELECT COUNT(*) FROM attachments WHERE legacy_source = 'lead_file' AND communication_id IS NOT NULL;
|
||||
* -- Beide Zahlen sollten übereinstimmen
|
||||
*
|
||||
* Rollback: NICHT möglich ohne Datenbank-Backup.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('lead_files');
|
||||
Schema::dropIfExists('booking_files');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Phase 4 Schritt 2c kann nicht automatisch zurückgerollt werden. ' .
|
||||
'Bitte Datenbank-Backup einspielen.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 1 von 7.
|
||||
*
|
||||
* Erstellt die Haupttabelle `offers`. Jeder Datensatz hier ist ein
|
||||
* logisches Angebot (eine Angebotsnummer). Die tatsächlichen Inhalte
|
||||
* (Texte, Positionen, Preise, PDF, Dokumente) liegen versionsweise in
|
||||
* `offer_versions`. Ab dem ersten Versand erzeugt jede Änderung eine
|
||||
* neue Version (Entscheidung 17.1 des Entwicklungsplans).
|
||||
*
|
||||
* VORBEDINGUNG:
|
||||
* - Modul 3 Phase 2 (Tabellen `contacts` + `inquiries`) muss
|
||||
* eingespielt sein. Siehe dev/customer-bookings/umsetzung.md.
|
||||
*
|
||||
* Tabelle `offers.current_version_id` wird als FK erst in der Migration
|
||||
* 2026_04_17_100007 gesetzt (zyklische Abhängigkeit zu `offer_versions`).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offers', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('offer_number', 32)->unique();
|
||||
|
||||
$t->foreignId('contact_id')
|
||||
->constrained('contacts')
|
||||
->restrictOnDelete();
|
||||
|
||||
$t->foreignId('inquiry_id')
|
||||
->nullable()
|
||||
->constrained('inquiries')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->foreignId('booking_id')
|
||||
->nullable()
|
||||
->constrained('booking')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->enum('status', [
|
||||
'draft',
|
||||
'sent',
|
||||
'accepted',
|
||||
'declined',
|
||||
'expired',
|
||||
'withdrawn',
|
||||
])->default('draft');
|
||||
|
||||
// FK wird in 2026_04_17_100007 nachträglich gesetzt
|
||||
$t->unsignedBigInteger('current_version_id')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['status', 'contact_id']);
|
||||
$t->index('inquiry_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offers');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 2 von 7.
|
||||
*
|
||||
* Versionstabelle der Angebote. Jede Änderung nach dem ersten Versand
|
||||
* erzeugt hier einen neuen Datensatz (version_no = max+1). Eine Version
|
||||
* kapselt den kompletten Inhalt (Texte, Summe, Gültigkeit, PDF-Pfad,
|
||||
* Status). Positionen (`offer_items`) und Anhänge (`offer_files`) hängen
|
||||
* an einer Version — nicht am übergeordneten `offers`-Datensatz.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100001_create_offers_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_versions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_id')
|
||||
->constrained('offers')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->unsignedInteger('version_no');
|
||||
|
||||
$t->enum('status', [
|
||||
'draft',
|
||||
'sent',
|
||||
'accepted',
|
||||
'declined',
|
||||
'expired',
|
||||
'superseded',
|
||||
])->default('draft');
|
||||
|
||||
$t->date('valid_until')->nullable();
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
|
||||
$t->string('headline')->nullable();
|
||||
$t->text('intro_text')->nullable();
|
||||
$t->longText('itinerary_text')->nullable();
|
||||
$t->text('closing_text')->nullable();
|
||||
|
||||
// offer_templates wird in Migration 4 erzeugt — FK dort,
|
||||
// hier zunächst nullable + FK wird über Migration 4 nachgeholt
|
||||
$t->unsignedBigInteger('template_id')->nullable();
|
||||
|
||||
$t->string('pdf_path')->nullable();
|
||||
$t->boolean('pdf_archived')->default(false);
|
||||
|
||||
$t->dateTime('sent_at')->nullable();
|
||||
$t->dateTime('accepted_at')->nullable();
|
||||
|
||||
$t->enum('accepted_via', [
|
||||
'customer_link',
|
||||
'admin',
|
||||
'email',
|
||||
])->nullable();
|
||||
|
||||
// Referenz auf zentral hinterlegte Dokument-Vorlagen,
|
||||
// die mit dieser Version (als Anhang) verknüpft sind
|
||||
$t->json('template_document_ids')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->unique(['offer_id', 'version_no']);
|
||||
$t->index(['offer_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_versions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 3 von 7.
|
||||
*
|
||||
* Einzelne Leistungs-/Positionszeilen einer Angebotsversion.
|
||||
* Hängt an `offer_versions`, NICHT am Offer-Kopf — so bleiben
|
||||
* Positionen einer versendeten Version unveränderlich, während
|
||||
* eine neue Version ihre eigenen Positionen führt.
|
||||
*
|
||||
* `travel_program_id` und `fewo_lodging_id` bleiben FK-frei, solange
|
||||
* Modul 12 (v2-Reiseverwaltung) noch nicht nach Laravel migriert ist.
|
||||
* `metadata` speichert einen Snapshot (Titel, Preis zum Zeitpunkt der
|
||||
* Erstellung), damit Positionen auch nach einer späteren v2-Migration
|
||||
* lesbar bleiben (Risiko R4 im Ticket-Dokument).
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_items', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->unsignedInteger('position')->default(0);
|
||||
|
||||
$t->enum('type', [
|
||||
'travel',
|
||||
'service',
|
||||
'option',
|
||||
'discount',
|
||||
'insurance',
|
||||
'custom',
|
||||
]);
|
||||
|
||||
$t->string('title');
|
||||
$t->text('description')->nullable();
|
||||
|
||||
$t->unsignedInteger('quantity')->default(1);
|
||||
$t->decimal('price_per_unit', 10, 2)->default(0);
|
||||
$t->decimal('total_price', 10, 2)->default(0);
|
||||
|
||||
// Bewusst OHNE FK-Constraint — siehe Risiko R4
|
||||
$t->unsignedBigInteger('travel_program_id')->nullable();
|
||||
$t->unsignedBigInteger('fewo_lodging_id')->nullable();
|
||||
|
||||
$t->json('metadata')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_version_id', 'position']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 4 von 7.
|
||||
*
|
||||
* Wiederverwendbare Angebotsvorlagen (Text-/Positions-Blueprints, die
|
||||
* sich in Phase C in der Admin-UI pflegen lassen). Eine Vorlage liefert
|
||||
* Default-Texte + Default-Positionen für neue Angebote.
|
||||
*
|
||||
* Zusatz: Nachträgliche FK-Verknüpfung `offer_versions.template_id`,
|
||||
* weil Migration 2 noch nicht auf diese Tabelle verweisen konnte.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_templates', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
// `branch` existiert schon im CRM — Vorlagen können so pro
|
||||
// Filiale gepflegt werden. Eine spätere Erweiterung auf
|
||||
// `organization_id` (Modul 5) erfolgt additiv.
|
||||
$t->foreignId('branch_id')
|
||||
->nullable()
|
||||
->constrained('branch')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->string('name');
|
||||
$t->text('description')->nullable();
|
||||
|
||||
$t->string('default_headline')->nullable();
|
||||
$t->text('default_intro')->nullable();
|
||||
$t->longText('default_itinerary')->nullable();
|
||||
$t->text('default_closing')->nullable();
|
||||
|
||||
// Array aus [{title, description, type, price_per_unit, quantity}, …]
|
||||
$t->json('default_items')->nullable();
|
||||
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['branch_id', 'is_active']);
|
||||
});
|
||||
|
||||
Schema::table('offer_versions', function (Blueprint $t) {
|
||||
$t->foreign('template_id')
|
||||
->references('id')
|
||||
->on('offer_templates')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('offer_versions', function (Blueprint $t) {
|
||||
$t->dropForeign(['template_id']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('offer_templates');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 5 von 7.
|
||||
*
|
||||
* Dateiablage für Anhänge einer Angebotsversion (freie Uploads, später
|
||||
* auch PDF-Archivkopien). Struktur ist an `booking_files` angelehnt
|
||||
* (identifier, filename, dir, original_name, ext, mime, size), damit
|
||||
* `FileRepository::store()` direkt wiederverwendet werden kann.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_files', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->string('identifier', 64)->nullable();
|
||||
$t->string('filename');
|
||||
$t->string('dir');
|
||||
$t->string('original_name');
|
||||
$t->string('ext', 16);
|
||||
$t->string('mine', 128);
|
||||
$t->unsignedBigInteger('size')->default(0);
|
||||
|
||||
$t->boolean('include_in_pdf')->default(true);
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_version_id', 'identifier']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_files');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 6 von 7.
|
||||
*
|
||||
* Einmal-Token für den kundenseitigen Freigabe-Link
|
||||
* (/angebot/{token} → Phase D). Pro Angebot + Version genau ein aktiver
|
||||
* Token; bei Neuversand einer neuen Version wird der alte Token
|
||||
* `revoked` gesetzt. Tokens sind SHA-256-Hashes; der Klartext wird
|
||||
* ausschließlich im Mail-Link an den Kunden übergeben.
|
||||
*
|
||||
* VORBEDINGUNG: 2026_04_17_100002_create_offer_versions_table.php
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('offer_access_tokens', function (Blueprint $t) {
|
||||
$t->id();
|
||||
|
||||
$t->foreignId('offer_id')
|
||||
->constrained('offers')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->foreignId('offer_version_id')
|
||||
->constrained('offer_versions')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$t->string('token_hash', 64)->unique();
|
||||
$t->dateTime('expires_at')->nullable();
|
||||
$t->dateTime('first_opened_at')->nullable();
|
||||
$t->dateTime('revoked_at')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['offer_id', 'revoked_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('offer_access_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modul 6 — Angebote: Ticket A1 / Migration 7 von 7.
|
||||
*
|
||||
* Schließt die FK-Beziehungen, die in den vorherigen Migrationen wegen
|
||||
* zyklischer Abhängigkeiten nicht direkt gesetzt werden konnten:
|
||||
*
|
||||
* 1. `offers.current_version_id` → offer_versions.id (ON DELETE SET NULL)
|
||||
* 2. `bookings.offer_id` → offers.id (ON DELETE SET NULL)
|
||||
*
|
||||
* Letzteres erlaubt die Conversion „Angebot → Buchung" (Ticket B8)
|
||||
* und zeigt im Buchungsdatensatz, aus welchem Angebot er entstanden ist.
|
||||
*
|
||||
* VORBEDINGUNG: alle vorherigen Offers-Migrationen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('offers', function (Blueprint $t) {
|
||||
$t->foreign('current_version_id')
|
||||
->references('id')
|
||||
->on('offer_versions')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('booking', function (Blueprint $t) {
|
||||
// `inquiry_id` kommt aus Modul-3 Phase 2 (war vorher `lead_id`)
|
||||
$t->unsignedBigInteger('offer_id')->nullable()->after('inquiry_id');
|
||||
|
||||
$t->foreign('offer_id')
|
||||
->references('id')
|
||||
->on('offers')
|
||||
->nullOnDelete();
|
||||
|
||||
$t->index('offer_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('booking', function (Blueprint $t) {
|
||||
$t->dropForeign(['offer_id']);
|
||||
$t->dropIndex(['offer_id']);
|
||||
$t->dropColumn('offer_id');
|
||||
});
|
||||
|
||||
Schema::table('offers', function (Blueprint $t) {
|
||||
$t->dropForeign(['current_version_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
132
dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php
Normal file
132
dev/backups/phase2-offers-2026-04-17/FILES/models/Offer.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 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-read Collection|OfferVersion[] $versions
|
||||
* @property-read Collection|OfferAccessToken[] $accessTokens
|
||||
* @property-read User $creator
|
||||
*/
|
||||
class Offer extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
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';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_SENT,
|
||||
self::STATUS_ACCEPTED,
|
||||
self::STATUS_DECLINED,
|
||||
self::STATUS_EXPIRED,
|
||||
self::STATUS_WITHDRAWN,
|
||||
];
|
||||
|
||||
protected $table = 'offers';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue