129 lines
4.3 KiB
PHP
129 lines
4.3 KiB
PHP
<?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);
|
||
}
|
||
}
|