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