Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
25
database/factories/ContactFactory.php
Normal file
25
database/factories/ContactFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact>
|
||||
*/
|
||||
class ContactFactory extends Factory
|
||||
{
|
||||
protected $model = Contact::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->lastName(),
|
||||
'firstname' => fake()->firstName(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'city' => fake()->city(),
|
||||
'phone' => fake()->phoneNumber(),
|
||||
];
|
||||
}
|
||||
}
|
||||
85
database/factories/OfferFactory.php
Normal file
85
database/factories/OfferFactory.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Models\Offer;
|
||||
use App\Models\OfferVersion;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Offer>
|
||||
*/
|
||||
class OfferFactory extends Factory
|
||||
{
|
||||
protected $model = Offer::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$y = (int) now()->format('Y');
|
||||
|
||||
return [
|
||||
'offer_number' => $y . '-' . str_pad((string) fake()->unique()->numberBetween(1, 99_999), 5, '0', STR_PAD_LEFT),
|
||||
'contact_id' => Contact::factory(),
|
||||
'inquiry_id' => null,
|
||||
'booking_id' => null,
|
||||
'status' => Offer::STATUS_DRAFT,
|
||||
'current_version_id' => null,
|
||||
'created_by' => User::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterCreating(function (Offer $offer) {
|
||||
if ($offer->current_version_id) {
|
||||
return;
|
||||
}
|
||||
$v = OfferVersion::query()->create([
|
||||
'offer_id' => $offer->id,
|
||||
'version_no' => 1,
|
||||
'status' => OfferVersion::STATUS_DRAFT,
|
||||
'valid_until' => null,
|
||||
'total_price' => 0,
|
||||
'headline' => 'Test-Angebot',
|
||||
'created_by' => $offer->created_by,
|
||||
]);
|
||||
$offer->update(['current_version_id' => $v->id]);
|
||||
});
|
||||
}
|
||||
|
||||
public function sent(): static
|
||||
{
|
||||
return $this
|
||||
->state(['status' => Offer::STATUS_SENT])
|
||||
->afterCreating(function (Offer $offer) {
|
||||
$v = $offer->refresh()->currentVersion;
|
||||
if (! $v) {
|
||||
return;
|
||||
}
|
||||
$v->update([
|
||||
'status' => OfferVersion::STATUS_SENT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function accepted(): static
|
||||
{
|
||||
return $this
|
||||
->state(['status' => Offer::STATUS_ACCEPTED])
|
||||
->afterCreating(function (Offer $offer) {
|
||||
$v = $offer->refresh()->currentVersion;
|
||||
if (! $v) {
|
||||
return;
|
||||
}
|
||||
$v->update([
|
||||
'status' => OfferVersion::STATUS_ACCEPTED,
|
||||
'sent_at' => $v->sent_at ?? now(),
|
||||
'accepted_at' => now(),
|
||||
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
72
database/factories/OfferItemFactory.php
Normal file
72
database/factories/OfferItemFactory.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Offer;
|
||||
use App\Models\OfferItem;
|
||||
use App\Models\OfferVersion;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferItem>
|
||||
*/
|
||||
class OfferItemFactory extends Factory
|
||||
{
|
||||
protected $model = OfferItem::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$qty = fake()->numberBetween(1, 4);
|
||||
$ppu = (float) fake()->randomFloat(2, 200, 4_000);
|
||||
return [
|
||||
'position' => 0,
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => fake()->sentence(3),
|
||||
'description' => null,
|
||||
'quantity' => $qty,
|
||||
'price_per_unit' => $ppu,
|
||||
'total_price' => round($qty * $ppu, 2),
|
||||
'travel_program_id' => null,
|
||||
'fewo_lodging_id' => null,
|
||||
'metadata' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function asDiscount(float $absAmount = 150): static
|
||||
{
|
||||
return $this->state([
|
||||
'type' => OfferItem::TYPE_DISCOUNT,
|
||||
'title' => 'Rabatt',
|
||||
'quantity' => 1,
|
||||
'price_per_unit' => -1 * abs($absAmount),
|
||||
'total_price' => -1 * abs($absAmount),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt die Position an die aktuelle Version eines per Factory erstellten Angebots.
|
||||
*/
|
||||
public function forCurrentVersionOf(Offer $offer): static
|
||||
{
|
||||
$v = $offer->currentVersion;
|
||||
if (! $v) {
|
||||
throw new \InvalidArgumentException('Offer hat keine currentVersion — zuerst Offer::factory() erzeugen.');
|
||||
}
|
||||
return $this->state(fn (array $a) => [
|
||||
'offer_version_id' => $v->id,
|
||||
'position' => (int) $v->items()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function forVersion(OfferVersion $version, ?int $position = null): static
|
||||
{
|
||||
return $this->state(function (array $a) use ($version, $position) {
|
||||
$pos = $position ?? (int) $version->items()->count();
|
||||
|
||||
return [
|
||||
'offer_version_id' => $version->id,
|
||||
'position' => $pos,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
39
database/factories/OfferTemplateFactory.php
Normal file
39
database/factories/OfferTemplateFactory.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\OfferItem;
|
||||
use App\Models\OfferTemplate;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferTemplate>
|
||||
*/
|
||||
class OfferTemplateFactory extends Factory
|
||||
{
|
||||
protected $model = OfferTemplate::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'branch_id' => null,
|
||||
'name' => 'Vorlage ' . fake()->words(2, true),
|
||||
'description' => fake()->optional()->sentence(),
|
||||
'default_headline' => fake()->sentence(4),
|
||||
'default_intro' => '<p>' . fake()->paragraph() . '</p>',
|
||||
'default_itinerary' => null,
|
||||
'default_closing' => null,
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => 'Reiseleistung (Standard)',
|
||||
'quantity' => 2,
|
||||
'price_per_unit' => 1_250.00,
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
'created_by' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
70
database/factories/OfferVersionFactory.php
Normal file
70
database/factories/OfferVersionFactory.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Offer;
|
||||
use App\Models\OfferVersion;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OfferVersion>
|
||||
*/
|
||||
class OfferVersionFactory extends Factory
|
||||
{
|
||||
protected $model = OfferVersion::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
// `Offer::factory()` legt V1 in OfferFactory::afterCreating an — V2+ ist
|
||||
// pro (offer_id, version_no) unique.
|
||||
return [
|
||||
'offer_id' => Offer::factory(),
|
||||
'version_no' => 2,
|
||||
'status' => OfferVersion::STATUS_DRAFT,
|
||||
'valid_until' => null,
|
||||
'total_price' => fake()->randomFloat(2, 100, 15_000),
|
||||
'headline' => fake()->sentence(4),
|
||||
'intro_text' => '<p>' . fake()->paragraph() . '</p>',
|
||||
'itinerary_text' => null,
|
||||
'closing_text' => null,
|
||||
'template_id' => null,
|
||||
'pdf_path' => null,
|
||||
'pdf_archived' => false,
|
||||
'sent_at' => null,
|
||||
'accepted_at' => null,
|
||||
'accepted_via' => null,
|
||||
'template_document_ids' => null,
|
||||
'created_by' => User::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function forOffer(Offer $offer, int $versionNo = 2): static
|
||||
{
|
||||
return $this->state(function (array $a) use ($offer, $versionNo) {
|
||||
return [
|
||||
'offer_id' => $offer->id,
|
||||
'version_no' => $versionNo,
|
||||
'created_by' => $offer->created_by,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function versionSent(): static
|
||||
{
|
||||
return $this->state([
|
||||
'status' => OfferVersion::STATUS_SENT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function versionAccepted(): static
|
||||
{
|
||||
return $this->state([
|
||||
'status' => OfferVersion::STATUS_ACCEPTED,
|
||||
'sent_at' => now(),
|
||||
'accepted_at' => now(),
|
||||
'accepted_via' => OfferVersion::ACCEPTED_VIA_ADMIN,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Database\Migrations\Migration;
|
|||
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
|
||||
* @see https://github.com/cviebrock/sequel-pro-laravel-export
|
||||
*/
|
||||
class CreateBookingVoucherTable extends Migration
|
||||
class CreateBookingVoucherAgencyTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
/**
|
||||
* Migration auto-generated by Sequel Pro Laravel Export (1.4.1)
|
||||
* @see https://github.com/cviebrock/sequel-pro-laravel-export
|
||||
*/
|
||||
class CreateBookingVoucherTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('booking_voucher', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('booking_id');
|
||||
$table->binary('binary_data');
|
||||
$table->dateTime('created_at');
|
||||
$table->dateTime('updated_at');
|
||||
|
||||
$table->index('booking_id', 'booking_voucher_booking_id_idx');
|
||||
|
||||
$table->foreign('booking_id', 'booking_voucher_booking_id_booking_id')->references('id')->on('booking')->onDelete('CASCADE
|
||||
')->onUpdate('RESTRICT');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('booking_voucher');
|
||||
}
|
||||
}
|
||||
|
|
@ -28,19 +28,14 @@ return new class extends Migration
|
|||
$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();
|
||||
// FK-Typen MÜSSEN exakt zur Referenz passen.
|
||||
// Die Legacy-Tabellen contacts/inquiries/booking sind aus dem
|
||||
// alten Sequel-Pro-Export mit `bigint` (SIGNED) angelegt; die
|
||||
// users-Tabelle nutzt `int unsigned`. Daher hier explizit
|
||||
// typisieren statt foreignId() (das würde bigint UNSIGNED erzeugen).
|
||||
$t->bigInteger('contact_id');
|
||||
$t->bigInteger('inquiry_id')->nullable();
|
||||
$t->bigInteger('booking_id')->nullable();
|
||||
|
||||
$t->enum('status', [
|
||||
'draft',
|
||||
|
|
@ -54,11 +49,17 @@ return new class extends Migration
|
|||
// FK wird in 2026_04_17_100007 nachträglich gesetzt
|
||||
$t->unsignedBigInteger('current_version_id')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
// users.id ist int unsigned (Legacy)
|
||||
$t->unsignedInteger('created_by');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->foreign('contact_id')->references('id')->on('contacts')->restrictOnDelete();
|
||||
$t->foreign('inquiry_id')->references('id')->on('inquiries')->nullOnDelete();
|
||||
$t->foreign('booking_id')->references('id')->on('booking')->nullOnDelete();
|
||||
$t->foreign('created_by')->references('id')->on('users');
|
||||
|
||||
$t->index(['status', 'contact_id']);
|
||||
$t->index('inquiry_id');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,10 +65,13 @@ return new class extends Migration
|
|||
// die mit dieser Version (als Anhang) verknüpft sind
|
||||
$t->json('template_document_ids')->nullable();
|
||||
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
// users.id ist int unsigned (Legacy) → siehe Migration 100001
|
||||
$t->unsignedInteger('created_by');
|
||||
|
||||
$t->timestamps();
|
||||
|
||||
$t->foreign('created_by')->references('id')->on('users');
|
||||
|
||||
$t->unique(['offer_id', 'version_no']);
|
||||
$t->index(['offer_id', 'status']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,10 +26,8 @@ return new class extends Migration
|
|||
// `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();
|
||||
// FK-Typen: branch.id ist bigint signed, users.id int unsigned.
|
||||
$t->bigInteger('branch_id')->nullable();
|
||||
|
||||
$t->string('name');
|
||||
$t->text('description')->nullable();
|
||||
|
|
@ -43,11 +41,14 @@ return new class extends Migration
|
|||
$t->json('default_items')->nullable();
|
||||
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->unsignedInteger('created_by');
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->foreign('branch_id')->references('id')->on('branch')->nullOnDelete();
|
||||
$t->foreign('created_by')->references('id')->on('users');
|
||||
|
||||
$t->index(['branch_id', 'is_active']);
|
||||
});
|
||||
|
||||
|
|
|
|||
115
database/seeds/OfferTemplateSeeder.php
Normal file
115
database/seeds/OfferTemplateSeeder.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\OfferItem;
|
||||
use App\Models\OfferTemplate;
|
||||
use App\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Legt 5 Angebots-Vorlagen für manuelle/QA-Tests an.
|
||||
*
|
||||
* Ausführung: `php artisan db:seed --class=Database\\Seeders\\OfferTemplateSeeder`
|
||||
* (benötigt mindestens einen User in `users` und ausgeführte Offer-Migrationen).
|
||||
*/
|
||||
class OfferTemplateSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$userId = User::query()->orderBy('id')->value('id');
|
||||
if (! $userId) {
|
||||
$this->command?->warn('OfferTemplateSeeder: kein User — übersprungen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$templates = [
|
||||
[
|
||||
'name' => 'Südafrika – Klassik (14 Tage)',
|
||||
'description' => 'Kombirundreise, Baustein für B2-Modal-Tests',
|
||||
'default_headline' => 'Ihr Südafrika-Erlebnis',
|
||||
'default_intro' => '<p>Sehr geehrte Gäste, wir freuen uns, Ihnen folgendes Reiseangebot zu unterbreiten.</p>',
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => 'Rundreise 14 Tage inkl. Mietwagen',
|
||||
'quantity' => 2,
|
||||
'price_per_unit' => 1_800.00,
|
||||
],
|
||||
[
|
||||
'type' => OfferItem::TYPE_OPTION,
|
||||
'title' => 'Business-Class-Aufpreis (Flug)',
|
||||
'quantity' => 2,
|
||||
'price_per_unit' => 450.00,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Namibia – Self-Drive',
|
||||
'default_headline' => 'Namibia per Mietwagen',
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => 'Mietwagen 10 Tage',
|
||||
'quantity' => 1,
|
||||
'price_per_unit' => 890.00,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Mauritius – Strand (8 Nächte)',
|
||||
'default_headline' => 'Honeymoon-Insel',
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => 'Hotel 5* Halbpension (p.P.)',
|
||||
'quantity' => 2,
|
||||
'price_per_unit' => 1_200.00,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Kurzangebot – Städtereise',
|
||||
'default_headline' => 'Wochenend-Trip',
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_SERVICE,
|
||||
'title' => 'Transfer Flughafen',
|
||||
'quantity' => 1,
|
||||
'price_per_unit' => 75.00,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Rabatt-Template (Frühbucher)',
|
||||
'default_headline' => 'Frühbucher-Sonderkonditionen',
|
||||
'default_items' => [
|
||||
[
|
||||
'type' => OfferItem::TYPE_TRAVEL,
|
||||
'title' => 'Basispaket',
|
||||
'quantity' => 2,
|
||||
'price_per_unit' => 1_000.00,
|
||||
],
|
||||
[
|
||||
'type' => OfferItem::TYPE_DISCOUNT,
|
||||
'title' => 'Frühbucher-Rabatt',
|
||||
'quantity' => 1,
|
||||
'price_per_unit' => -200.00,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($templates as $t) {
|
||||
OfferTemplate::query()->updateOrCreate(
|
||||
['name' => $t['name']],
|
||||
array_merge($t, [
|
||||
'branch_id' => null,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue