mein-sterntours/app/Repositories/OfferVersionRepository.php

207 lines
6.9 KiB
PHP

<?php
namespace App\Repositories;
use App\Models\Offer;
use App\Models\OfferItem;
use App\Models\OfferVersion;
use Illuminate\Support\Facades\DB;
/**
* Repository für Angebots-Versionen.
*
* Zentrale Invarianten:
* - Nur Draft-Versionen sind editierbar (sonst `DomainException`).
* - `createNewVersion()` dupliziert alle Items + Files, markiert die alte
* Version als `superseded` und schiebt `offer.current_version_id` um.
* - `markSent()` friert die Version ein (status=sent, sent_at=now) und hebt
* den Offer-Status von `draft` auf `sent`.
*/
class OfferVersionRepository extends BaseRepository
{
public function __construct(OfferVersion $model)
{
$this->model = $model;
}
/**
* Aktualisiert eine Draft-Version und synchronisiert ggf. die Items.
* Total-Preis wird anschließend aus den Items neu aufsummiert.
*
* @param array<string,mixed> $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<int, array<string,mixed>> $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();
}
}