Neustrukturierung Customer / Lead / Booking Phase 2

This commit is contained in:
Kevin Adametz 2026-05-28 17:10:37 +02:00
parent 313f0dbf4e
commit 6df9c401af
69 changed files with 3809 additions and 374 deletions

View file

@ -0,0 +1,100 @@
<?php
namespace App\Repositories;
use App\Models\OfferFile;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Response;
/**
* Datei-Repository für Angebots-Anhänge.
*
* Bewusst API-kompatibel mit {@see BookingFileRepository} gehalten:
* identische JSON-Response-Struktur, so dass die Dropzone-/Upload-
* Frontends aus dem Booking-Modul ohne Anpassung geteilt werden können.
*
* Unterschiede:
* - Persistiert in `offer_files` mit `offer_version_id` statt `booking_id`.
* - Zusätzliches Feld `include_in_pdf` (bool, default true) steuert, ob
* der Anhang in der PDF-Generierung mitgeführt wird (Ticket B5).
*/
class OfferFileRepository extends FileRepository
{
/** @var OfferFile|null Nach save() gesetzt. */
protected $offer_file;
protected $offer_version_id;
protected $identifier;
protected bool $include_in_pdf = true;
public function __construct(OfferFile $model)
{
parent::__construct();
$this->model = $model;
/**
* Offer-Upload erlaubt zusätzlich gängige Dokument-Formate
* (Reise-Exposés als doc/docx, Kalkulationen als xlsx).
*/
$this->rules = [
'file' => 'required|mimes:pdf,jpeg,jpg,png,doc,docx,xls,xlsx|max:32768',
];
$this->messages = [
'file.mimes' => 'Datei ist kein PDF/JPG/PNG/DOC/XLS Format',
'file.required' => 'Datei wird benötigt',
];
}
public function save(): void
{
$this->offer_file = OfferFile::create([
'offer_version_id' => $this->offer_version_id,
'identifier' => $this->identifier,
'filename' => $this->allowed_filename,
'dir' => $this->dir,
'original_name' => $this->originalName,
'ext' => $this->extension,
'mine' => $this->mine,
'size' => $this->size,
'include_in_pdf' => $this->include_in_pdf,
]);
}
public function response(): JsonResponse
{
return Response::json([
'error' => false,
'filename' => $this->allowed_filename,
'file_id' => $this->offer_file->id,
'file_data' => $this->extension,
'file_icon' => $this->offer_file->getIconExt(),
'file_format_bytes' => $this->offer_file->formatBytes(),
'file_url' => $this->offer_file->getURL(),
'include_in_pdf' => $this->offer_file->include_in_pdf,
'redirect' => '',
'code' => 200,
], 200);
}
/**
* Aktualisiert das `include_in_pdf`-Flag (für den Toggle in der UI).
* Nur erlaubt, wenn die Version noch editierbar ist (Draft).
*/
public function toggleIncludeInPdf(OfferFile $file, bool $include): OfferFile
{
$version = $file->offerVersion;
if ($version && ! $version->isEditable()) {
throw new \DomainException(
"OfferFile #{$file->id} gehört zu einer eingefrorenen Version "
. "(status={$version->status}) und kann nicht mehr geändert werden."
);
}
$file->include_in_pdf = $include;
$file->save();
return $file;
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Repositories;
use App\Models\Offer;
use App\Models\OfferVersion;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
/**
* Repository für das Angebot (Kopf-Datensatz).
*
* Konventionen:
* - Nur DB-Operationen, keine Business-Rules (die liegen im OfferService / A4).
* - Alle Multi-Statement-Operationen laufen in einer Transaktion.
* - `offer_number` ist UNIQUE auf DB-Ebene wir akzeptieren Race-Kollisionen
* und versuchen bis zu 3×, eine freie Nummer zu ziehen.
*/
class OfferRepository extends BaseRepository
{
/** Max. Retries beim Generieren einer freien Angebotsnummer. */
public const MAX_NUMBER_RETRIES = 3;
public function __construct(Offer $model)
{
$this->model = $model;
}
/**
* Legt ein neues Angebot + initiale Version 1 (status=draft) an.
* Rollback erfolgt, falls irgendein Teilschritt fehlschlägt.
*
* Erwartete Keys in `$data`:
* - `status` (optional, default 'draft')
* - `current_version`: array mit Daten für die erste OfferVersion
* (mindestens `created_by`, optional `headline`, `valid_until`, `template_id`)
*
* @param array<string,mixed> $data
*/
public function create(array $data, int $contactId, ?int $inquiryId = null, ?int $bookingId = null): Offer
{
return DB::transaction(function () use ($data, $contactId, $inquiryId, $bookingId) {
$versionData = $data['current_version'] ?? [];
$createdBy = $versionData['created_by'] ?? $data['created_by'] ?? null;
if (! $createdBy) {
throw new \InvalidArgumentException('OfferRepository::create benötigt created_by');
}
$offer = new Offer([
'offer_number' => $this->generateOfferNumber(),
'contact_id' => $contactId,
'inquiry_id' => $inquiryId,
'booking_id' => $bookingId,
'status' => $data['status'] ?? Offer::STATUS_DRAFT,
'created_by' => $createdBy,
]);
$offer->save();
$version = new OfferVersion(array_merge([
'status' => OfferVersion::STATUS_DRAFT,
'total_price' => 0,
'created_by' => $createdBy,
], $versionData, [
'offer_id' => $offer->id,
'version_no' => 1,
]));
$version->save();
$offer->current_version_id = $version->id;
$offer->save();
return $offer->fresh(['currentVersion']);
});
}
/**
* Erzeugt die nächste freie Angebotsnummer im Format `YYYY-NNNNN`.
* Race-Safety: `lockForUpdate()` serialisiert konkurrierende Generierungen
* pro Jahr. Zusätzlicher Schutz über den UNIQUE-Index auf `offer_number`.
*/
public function generateOfferNumber(): string
{
$year = Carbon::now()->format('Y');
$attempt = 0;
while (true) {
$attempt++;
try {
return DB::transaction(function () use ($year) {
$last = Offer::withTrashed()
->where('offer_number', 'LIKE', $year . '-%')
->orderByDesc('offer_number')
->lockForUpdate()
->value('offer_number');
$nextSeq = $last ? ((int) substr($last, 5)) + 1 : 1;
return sprintf('%s-%05d', $year, $nextSeq);
});
} catch (QueryException $e) {
if ($attempt >= self::MAX_NUMBER_RETRIES) {
throw $e;
}
usleep(50_000 * $attempt);
}
}
}
public function findByNumber(string $offerNumber): ?Offer
{
return Offer::where('offer_number', $offerNumber)->first();
}
/** Convenience für Controller: Offer inkl. Version/Items/Files eager-loaden. */
public function getByIdFull(int $id): Offer
{
return Offer::with([
'contact',
'inquiry',
'booking',
'currentVersion.items',
'currentVersion.files',
'currentVersion.tokens',
'versions' => fn ($q) => $q->orderBy('version_no'),
'createdBy',
])->findOrFail($id);
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Repositories;
use App\Models\OfferItem;
use App\Models\OfferTemplate;
use App\Models\OfferVersion;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Repository für Angebots-Vorlagen.
*
* Vorlagen bestehen aus Default-Texten (Headline/Intro/Itinerary/Closing)
* und einer Default-Positionsliste (JSON in `default_items`). Über
* {@see applyTo()} wird eine Vorlage in eine existierende Draft-Version
* hineinkopiert (Texte + Positionen).
*/
class OfferTemplateRepository extends BaseRepository
{
public function __construct(OfferTemplate $model)
{
$this->model = $model;
}
/**
* @param array<string,mixed> $data
*/
public function create(array $data): OfferTemplate
{
return OfferTemplate::create($data);
}
/**
* @param array<string,mixed> $data
*/
public function update(OfferTemplate $template, array $data): OfferTemplate
{
$template->fill($data);
$template->save();
return $template->fresh();
}
/**
* Aktive Vorlagen, optional gefiltert nach Branch (inkl. global = branch_id NULL).
*
* @return Collection<int, OfferTemplate>
*/
public function listActive(?int $branchId = null): Collection
{
return OfferTemplate::query()
->active()
->forBranch($branchId)
->orderBy('name')
->get();
}
/**
* Wendet eine Vorlage auf eine Draft-Version an:
* - kopiert Default-Texte (nur in leere Felder, bestehender Text wird nicht überschrieben)
* - legt Positionen aus `default_items` an (ersetzt bestehende Items komplett)
* - merkt sich die Vorlage in `offer_version.template_id`
*
* @return OfferVersion fresh, inkl. items
*/
public function applyTo(OfferTemplate $template, OfferVersion $version): OfferVersion
{
if (! $version->isEditable()) {
throw new \DomainException(
"OfferVersion #{$version->id} ist eingefroren (status={$version->status}); "
. 'Vorlage kann nicht mehr angewendet werden.'
);
}
return DB::transaction(function () use ($template, $version) {
$version->template_id = $template->id;
$version->headline = $version->headline ?: $template->default_headline;
$version->intro_text = $version->intro_text ?: $template->default_intro;
$version->itinerary_text = $version->itinerary_text ?: $template->default_itinerary;
$version->closing_text = $version->closing_text ?: $template->default_closing;
$version->save();
OfferItem::where('offer_version_id', $version->id)->delete();
$position = 0;
$sum = 0.0;
foreach ((array) $template->default_items as $itemData) {
$item = new OfferItem(array_merge($itemData, [
'offer_version_id' => $version->id,
'position' => $position++,
]));
$item->recomputeTotal();
$item->save();
$sum += (float) $item->total_price;
}
$version->total_price = $sum;
$version->save();
return $version->fresh(['items']);
});
}
}

View file

@ -0,0 +1,207 @@
<?php
namespace App\Repositories;
use App\Models\Offer;
use App\Models\OfferItem;
use App\Models\OfferVersion;
use Illuminate\Support\Facades\DB;
/**
* Repository für Angebots-Versionen.
*
* Zentrale Invarianten:
* - Nur Draft-Versionen sind editierbar (sonst `DomainException`).
* - `createNewVersion()` dupliziert alle Items + Files, markiert die alte
* Version als `superseded` und schiebt `offer.current_version_id` um.
* - `markSent()` friert die Version ein (status=sent, sent_at=now) und hebt
* den Offer-Status von `draft` auf `sent`.
*/
class OfferVersionRepository extends BaseRepository
{
public function __construct(OfferVersion $model)
{
$this->model = $model;
}
/**
* Aktualisiert eine Draft-Version und synchronisiert ggf. die Items.
* Total-Preis wird anschließend aus den Items neu aufsummiert.
*
* @param array<string,mixed> $data beliebige OfferVersion-Felder + optional 'items' => [...]
*/
public function updateDraft(OfferVersion $v, array $data): OfferVersion
{
if (! $v->isEditable()) {
throw new \DomainException(
"OfferVersion #{$v->id} ist nicht mehr editierbar (status={$v->status}). "
. 'Für Änderungen an versendeten Versionen createNewVersion() nutzen.'
);
}
return DB::transaction(function () use ($v, $data) {
$items = $data['items'] ?? null;
unset($data['items']);
$v->fill($data);
$v->save();
if (is_array($items)) {
$this->syncItems($v, $items);
$v->total_price = (float) OfferItem::where('offer_version_id', $v->id)->sum('total_price');
$v->save();
}
return $v->fresh(['items']);
});
}
/**
* Dupliziert die aktuelle Version eines Offers in eine neue Draft-Version.
*
* - `version_no` = MAX(version_no) + 1
* - Items + Files werden als neue Zeilen kopiert (Datei-Binaries bleiben
* physisch geteilt die Datei-Zeile bekommt nur eine neue DB-ID).
* - Alte Version `superseded` (eingefroren).
* - `offer.current_version_id` wird auf die neue Version gesetzt.
*
* Alles atomar in einer Transaktion.
*/
public function createNewVersion(Offer $offer): OfferVersion
{
return DB::transaction(function () use ($offer) {
/** @var OfferVersion|null $current */
$current = $offer->currentVersion()->with(['items', 'files'])->first();
if (! $current) {
throw new \RuntimeException("Offer #{$offer->id} hat keine currentVersion.");
}
$nextVersionNo = ((int) $offer->versions()->max('version_no')) + 1;
$new = $current->replicate([
'version_no',
'status',
'sent_at',
'accepted_at',
'accepted_via',
'pdf_path',
'pdf_archived',
'created_at',
'updated_at',
]);
$new->version_no = $nextVersionNo;
$new->status = OfferVersion::STATUS_DRAFT;
$new->sent_at = null;
$new->accepted_at = null;
$new->accepted_via = null;
$new->pdf_path = null;
$new->pdf_archived = false;
$new->save();
foreach ($current->items as $item) {
$newItem = $item->replicate(['offer_version_id', 'created_at', 'updated_at']);
$newItem->offer_version_id = $new->id;
$newItem->save();
}
foreach ($current->files as $file) {
$newFile = $file->replicate(['offer_version_id', 'created_at', 'updated_at']);
$newFile->offer_version_id = $new->id;
$newFile->save();
}
$current->status = OfferVersion::STATUS_SUPERSEDED;
$current->save();
$offer->current_version_id = $new->id;
$offer->status = Offer::STATUS_DRAFT;
$offer->save();
return $new->fresh(['items', 'files']);
});
}
/**
* Markiert eine Version als versendet und hebt das Offer (falls noch draft)
* auf Status `sent`. Idempotent: erneutes Aufrufen ändert nichts.
*/
public function markSent(OfferVersion $v): OfferVersion
{
if ($v->status === OfferVersion::STATUS_SENT) {
return $v;
}
return DB::transaction(function () use ($v) {
$v->status = OfferVersion::STATUS_SENT;
$v->sent_at = $v->sent_at ?? now();
$v->save();
$offer = $v->offer;
if ($offer && $offer->status === Offer::STATUS_DRAFT) {
$offer->status = Offer::STATUS_SENT;
$offer->save();
}
return $v->fresh();
});
}
/**
* Markiert eine Version als akzeptiert (durch Admin oder Kunde-via-Token).
* Setzt gleichzeitig den Offer-Status auf `accepted`.
*/
public function markAccepted(OfferVersion $v, string $via = OfferVersion::ACCEPTED_VIA_ADMIN): OfferVersion
{
return DB::transaction(function () use ($v, $via) {
$v->status = OfferVersion::STATUS_ACCEPTED;
$v->accepted_at = $v->accepted_at ?? now();
$v->accepted_via = $via;
$v->save();
$offer = $v->offer;
if ($offer && $offer->status !== Offer::STATUS_ACCEPTED) {
$offer->status = Offer::STATUS_ACCEPTED;
$offer->save();
}
return $v->fresh();
});
}
/**
* Synchronisiert die Items einer Version mit einer Item-Liste:
* vorhandene `id`s werden aktualisiert, neue angelegt, fehlende gelöscht.
* Position wird aus Array-Index übernommen (0-basiert).
*
* @param array<int, array<string,mixed>> $items
*/
protected function syncItems(OfferVersion $v, array $items): void
{
$keepIds = [];
foreach (array_values($items) as $position => $itemData) {
$itemData['offer_version_id'] = $v->id;
$itemData['position'] = $position;
if (! empty($itemData['id'])) {
/** @var OfferItem $model */
$model = OfferItem::where('offer_version_id', $v->id)
->where('id', $itemData['id'])
->firstOrFail();
unset($itemData['id']);
$model->fill($itemData);
} else {
unset($itemData['id']);
$model = new OfferItem($itemData);
}
$model->recomputeTotal();
$model->save();
$keepIds[] = $model->id;
}
OfferItem::where('offer_version_id', $v->id)
->when(! empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
->delete();
}
}

View file

@ -58,6 +58,10 @@ class TravelUserBookingFewoRepository extends BaseRepository
{
$model = TravelUserBookingFewo::findOrFail($id);
if (!$model->invoice_number) {
return false;
}
$travel_info_user_text = str_replace("", "&euro;", $travel_info_user_text);
$model->info_mail_text = $travel_info_user_text;
$model->save();
@ -70,6 +74,9 @@ class TravelUserBookingFewoRepository extends BaseRepository
$path = $model->getTravelInfoPath();
$dir = $model->getTravelInfoDir();
$filename = $model->getTravelInfoFileName();
if (!$filename) {
return false;
}
$pdf_file = new CreatePDF('pdf.travel_info_fewo', 'fewo_infos');
$pdf_file->create($data, $filename, 'save', $path);
$pdf_file->merger($dir, $filename, 'sterntours-template-logo');