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,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);
}
}