Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
100
app/Repositories/OfferFileRepository.php
Normal file
100
app/Repositories/OfferFileRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/Repositories/OfferRepository.php
Normal file
129
app/Repositories/OfferRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
104
app/Repositories/OfferTemplateRepository.php
Normal file
104
app/Repositories/OfferTemplateRepository.php
Normal 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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
207
app/Repositories/OfferVersionRepository.php
Normal file
207
app/Repositories/OfferVersionRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,10 @@ class TravelUserBookingFewoRepository extends BaseRepository
|
|||
{
|
||||
|
||||
$model = TravelUserBookingFewo::findOrFail($id);
|
||||
if (!$model->invoice_number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$travel_info_user_text = str_replace("€", "€", $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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue