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

@ -0,0 +1,69 @@
<?php
namespace Database\Factories;
use App\Models\Booking;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Booking>
*/
class BookingFactory extends Factory
{
protected $model = Booking::class;
public function definition(): array
{
$start = fake()->dateTimeBetween('+1 month', '+6 months');
$end = fake()->dateTimeBetween($start, '+12 months');
return [
'customer_id' => CustomerFactory::new(),
'lead_id' => LeadFactory::new(),
'booking_date' => now()->format('Y-m-d'),
'start_date' => $start->format('Y-m-d'),
'end_date' => $end->format('Y-m-d'),
'sf_guard_user_id' => null,
'branch_id' => null,
'service_fee' => 0.0,
'travel_country_id' => null,
'travel_category_id' => null,
'pax' => 2,
'coupon_id' => null,
'title' => fake()->sentence(4),
'travel_number' => strtoupper(fake()->bothify('??####')),
'participant_name' => fake()->lastName(),
'participant_firstname' => fake()->firstName(),
'participant_birthdate' => fake()->dateTimeBetween('-60 years', '-18 years')->format('Y-m-d'),
'participant_salutation_id' => 1,
'ev_number' => '',
'merlin_knr' => '',
'merlin_order_number' => '',
'travel_company_id' => null,
'travel_documents' => false,
'price' => fake()->randomFloat(2, 500, 5000),
'price_total' => 0.0,
'deposit_total' => 0.0,
'final_payment' => 0.0,
'final_payment_date' => null,
'travelagenda_id' => null,
'website_id' => null,
'new_drafts' => false,
];
}
public function canceled(): static
{
return $this->state(fn (array $attributes) => [
'canceled' => 1.0,
]);
}
public function withPrice(float $price): static
{
return $this->state(fn (array $attributes) => [
'price' => $price,
'price_total' => $price,
]);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Customer>
*/
class CustomerFactory extends Factory
{
protected $model = Customer::class;
public function definition(): array
{
return [
'salutation_id' => 1,
'title' => '',
'name' => fake()->lastName(),
'firstname' => fake()->firstName(),
'birthdate' => fake()->dateTimeBetween('-80 years', '-18 years')->format('Y-m-d'),
'company' => '',
'street' => fake()->streetAddress(),
'zip' => fake()->postcode(),
'city' => fake()->city(),
'email' => fake()->unique()->safeEmail(),
'phone' => fake()->phoneNumber(),
'phonebusiness' => '',
'phonemobile' => fake()->phoneNumber(),
'fax' => '',
'bank' => '',
'bank_code' => '',
'bank_account_number' => '',
'credit_card_type_id' => null,
'credit_card_number' => '',
'credit_card_expiration_date'=> null,
'participants_remarks' => '',
'miscellaneous_remarks' => '',
'country_id' => null,
];
}
public function company(): static
{
return $this->state(fn (array $attributes) => [
'company' => fake()->company(),
]);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Database\Factories;
use App\Models\Lead;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Lead>
*/
class LeadFactory extends Factory
{
protected $model = Lead::class;
public function definition(): array
{
$start = fake()->dateTimeBetween('+1 month', '+6 months');
$end = fake()->dateTimeBetween($start, '+12 months');
return [
'customer_id' => CustomerFactory::new(),
'request_date' => now()->format('Y-m-d'),
'travelperiod_start' => $start->format('Y-m-d'),
'travelperiod_end' => $end->format('Y-m-d'),
'travelperiod_length' => (int) $start->diff($end)->days,
'travelcountry_id' => null,
'travelagenda_id' => null,
'remarks' => '',
'sf_guard_user_id' => null,
'is_closed' => false,
'initialcontacttype_id' => null,
'searchengine_id' => null,
'searchengine_keywords' => '',
'status_id' => 1,
'next_due_date' => now()->addDays(7)->format('Y-m-d'),
'website_id' => null,
'travelcategory_id' => null,
'price' => 0.0,
'pax' => 2,
'participant_name' => fake()->lastName(),
'participant_firstname' => fake()->firstName(),
'participant_birthdate' => fake()->dateTimeBetween('-60 years', '-18 years')->format('Y-m-d'),
'participant_salutation_id' => 1,
];
}
public function closed(): static
{
return $this->state(fn (array $attributes) => [
'is_closed' => true,
]);
}
public function withPrice(float $price): static
{
return $this->state(fn (array $attributes) => [
'price' => $price,
]);
}
}

View file

@ -1,23 +1,43 @@
<?php
use Faker\Generator as Faker;
namespace Database\Factories;
/*
|--------------------------------------------------------------------------
| Model Factories
|--------------------------------------------------------------------------
|
| This directory should contain each of the model factory definitions for
| your application. Factories provide a convenient way to generate new
| model instances for testing / seeding your application's database.
|
*/
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
$factory->define(App\User::class, function (Faker $faker) {
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
'remember_token' => str_random(10),
];
});
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\User>
*/
class UserFactory extends Factory
{
protected $model = User::class;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
'remember_token' => Str::random(10),
'active' => 1,
'admin' => 0,
'confirmed' => 1,
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'admin' => 1,
]);
}
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'active' => 0,
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Phase 1 Contact-Deduplizierung
*
* Fügt der customer-Tabelle zwei Felder hinzu:
* - merged_into_id : zeigt auf den Master-Datensatz bei Duplikaten
* - merged_at : Zeitstempel der Zusammenführung
*
* Rollback: Felder werden wieder entfernt. Keine Datenverluste,
* da keine bestehenden Daten verändert werden.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('customer', function (Blueprint $table) {
$table->unsignedBigInteger('merged_into_id')->nullable()->after('id');
$table->dateTime('merged_at')->nullable()->after('merged_into_id');
$table->index('merged_into_id', 'customer_merged_into_id_idx');
});
}
public function down(): void
{
Schema::table('customer', function (Blueprint $table) {
$table->dropIndex('customer_merged_into_id_idx');
$table->dropColumn(['merged_into_id', 'merged_at']);
});
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Phase 1 Schritt 3: Soft Delete für customer-Tabelle
*
* Fügt deleted_at hinzu. Gelöschte Kontakte bleiben in der DB erhalten
* und können wiederhergestellt werden.
*
* Das alte Customer-Model nutzt kein SoftDeletes unberührt.
* Nur der neue Contact-Model (mit SoftDeletes-Trait) berücksichtigt deleted_at.
*
* Rollback: entfernt deleted_at (alle soft-deleted Datensätze werden damit
* dauerhaft ausgeblendet erst prüfen!).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('customer', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('customer', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View file

@ -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`');
}
};

View file

@ -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`');
}
};

View file

@ -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');
});
}
};

View file

@ -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');
}
};

View file

@ -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.'
);
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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.'
);
}
};

View file

@ -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.'
);
}
};

View file

@ -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.'
);
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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']);
});
}
};