Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue