model = $model; } /** * Aktualisiert eine Draft-Version und synchronisiert ggf. die Items. * Total-Preis wird anschließend aus den Items neu aufsummiert. * * @param array $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> $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(); } }