offers->create( data: ['current_version' => ['created_by' => $this->resolveUserId($createdBy)]], contactId: $contactId, ); if ($templateId !== null) { $template = OfferTemplate::findOrFail($templateId); $this->templates->applyTo($template, $offer->currentVersion); $offer->refresh(); } return $offer->load(['currentVersion.items', 'contact']); }); } /** * Legt ein Angebot aus einer Anfrage (Inquiry / Lead) an: Kontakt wird * übernommen, `offers.inquiry_id` verknüpft. */ public function createFromInquiry(int $inquiryId, ?int $templateId = null, ?int $createdBy = null): Offer { return DB::transaction(function () use ($inquiryId, $templateId, $createdBy) { /** @var Lead $inquiry */ $inquiry = Lead::findOrFail($inquiryId); $offer = $this->offers->create( data: ['current_version' => ['created_by' => $this->resolveUserId($createdBy)]], contactId: (int) $inquiry->customer_id, inquiryId: $inquiryId, ); if ($templateId !== null) { $template = OfferTemplate::findOrFail($templateId); $this->templates->applyTo($template, $offer->currentVersion); $offer->refresh(); } return $offer->load(['currentVersion.items', 'contact', 'inquiry']); }); } /** * Wendet eine Vorlage auf eine (Draft-)Version an. * Thin Wrapper um den Template-Repo — wirft {@see OfferNotEditableException} * statt der rohen DomainException, damit Caller sauber unterscheiden können. */ public function applyTemplate(OfferVersion $v, OfferTemplate $template): OfferVersion { if (! $v->isEditable()) { throw new OfferNotEditableException($v, 'Vorlage anwenden'); } return $this->templates->applyTo($template, $v); } // ───────────────────────────────────────────────────────────────────────── // Bearbeitung // ───────────────────────────────────────────────────────────────────────── /** * Aktualisiert Texte/Items einer Draft-Version. Bei `sent`/`superseded` * wird `OfferNotEditableException` geworfen — Caller sollte vorher * `supersede()` nutzen, um eine neue Draft-Version zu erzeugen. * * @param array $data */ public function updateVersion(OfferVersion $v, array $data): OfferVersion { if (! $v->isEditable()) { throw new OfferNotEditableException($v, 'aktualisieren'); } return $this->versions->updateDraft($v, $data); } /** * Erzeugt eine neue Draft-Version (Kopie der aktuellen), setzt die alte * auf `superseded` und invalidiert alle noch aktiven Access-Tokens der * Vorgänger-Version. */ public function supersede(OfferVersion $v): OfferVersion { $offer = $v->offer; return DB::transaction(function () use ($offer, $v) { foreach ($v->tokens()->whereNull('revoked_at')->get() as $token) { $token->revoke(); } return $this->versions->createNewVersion($offer); }); } // ───────────────────────────────────────────────────────────────────────── // Versand // ───────────────────────────────────────────────────────────────────────── /** * Versendet eine Draft-Version: erzeugt frischen Access-Token, setzt * status=sent + sent_at, hebt den Offer-Status (falls draft) auf sent * und dispatcht `OfferSent`. * * Die Mail selbst wird vom OfferSentListener (Ticket B6) versendet — * hier nur der Token + State-Wechsel, damit der State-Teil testbar bleibt. * * Guard: Version muss editierbar (= draft) sein UND die aktuelle des Offers. * * @param array $mailData Betreff/Body/CC aus Versand-Modal */ public function send(OfferVersion $v, array $mailData): void { if (! $v->isEditable()) { throw new OfferNotEditableException($v, 'versenden'); } if ($v->offer && $v->offer->current_version_id !== $v->id) { throw new OfferNotEditableException($v, 'versenden (nicht aktuelle Version)'); } DB::transaction(function () use ($v, $mailData) { $expiresAt = $this->extractExpires($mailData); $token = OfferAccessToken::generate($v, $expiresAt); $v = $this->versions->markSent($v); OfferSent::dispatch($v->offer, $v, $token, $mailData); }); } // ───────────────────────────────────────────────────────────────────────── // Admin-Statuswechsel // ───────────────────────────────────────────────────────────────────────── /** * Markiert eine versendete Version als `accepted`. Gilt für Admin-Klick * ebenso wie für den Customer-Link (via=`customer_link`). * * Regel: Nur auf `sent` möglich. Bei Erfolg wechselt auch der Offer * auf `accepted`; parallel noch aktive ältere Versionen werden NICHT * angefasst (sind ohnehin schon `superseded` durch createNewVersion, * oder gehören in einer sauberen History). */ public function markAccepted( OfferVersion $v, string $via = OfferVersion::ACCEPTED_VIA_ADMIN, ?string $ip = null, ?string $ua = null, ): void { if ($v->status !== OfferVersion::STATUS_SENT) { throw new \DomainException( "OfferVersion #{$v->id} ist nicht sent (status={$v->status}); " . 'accept nur auf versendeten Versionen möglich.' ); } DB::transaction(function () use ($v, $via, $ip, $ua) { $v = $this->versions->markAccepted($v, $via); OfferAccepted::dispatch($v->offer, $v, $via, $ip, $ua); }); } public function markDeclined(OfferVersion $v, string $via, ?string $reason = null): void { if (! in_array($v->status, [OfferVersion::STATUS_SENT, OfferVersion::STATUS_ACCEPTED], true)) { throw new \DomainException( "OfferVersion #{$v->id} kann in status={$v->status} nicht abgelehnt werden." ); } DB::transaction(function () use ($v, $via, $reason) { $v->status = OfferVersion::STATUS_DECLINED; $v->save(); $offer = $v->offer; if ($offer && in_array($offer->status, [Offer::STATUS_SENT, Offer::STATUS_ACCEPTED], true)) { $offer->status = Offer::STATUS_DECLINED; $offer->save(); } OfferDeclined::dispatch($offer, $v->fresh(), $via, $reason); }); } /** * Zieht den Offer vollständig zurück: status=withdrawn, alle aktiven * Tokens werden widerrufen. Danach kann weder Kunde noch Admin * zusagen; Historie (Versionen) bleibt erhalten. */ public function withdraw(Offer $offer, string $reason): void { if (in_array($offer->status, [Offer::STATUS_ACCEPTED, Offer::STATUS_WITHDRAWN], true)) { throw new InvalidOfferStatusException( $offer, [Offer::STATUS_DRAFT, Offer::STATUS_SENT, Offer::STATUS_DECLINED, Offer::STATUS_EXPIRED], 'withdraw', ); } DB::transaction(function () use ($offer, $reason) { $offer->status = Offer::STATUS_WITHDRAWN; $offer->save(); OfferAccessToken::query() ->where('offer_id', $offer->id) ->whereNull('revoked_at') ->update(['revoked_at' => now()]); OfferWithdrawn::dispatch($offer->fresh(), $reason); }); } // ───────────────────────────────────────────────────────────────────────── // Wartung // ───────────────────────────────────────────────────────────────────────── /** * Scheduler-Job (Ticket A7): setzt versendete Versionen mit abgelaufenem * `valid_until` auf `expired`, ebenso den Offer — aber nur, wenn es noch * keine neuere akzeptierte Version gibt. * * @return int Anzahl der expirierten Versionen. */ public function purgeExpired(): int { $today = Carbon::today(); $expired = OfferVersion::query() ->where('status', OfferVersion::STATUS_SENT) ->whereNotNull('valid_until') ->whereDate('valid_until', '<', $today) ->get(); $count = 0; foreach ($expired as $v) { DB::transaction(function () use ($v, &$count) { $v->status = OfferVersion::STATUS_EXPIRED; $v->save(); $offer = $v->offer; if ($offer && $offer->status === Offer::STATUS_SENT && $offer->current_version_id === $v->id ) { $offer->status = Offer::STATUS_EXPIRED; $offer->save(); } $count++; }); } return $count; } // ───────────────────────────────────────────────────────────────────────── // Konversion Offer → Buchung // ───────────────────────────────────────────────────────────────────────── /** * Wandelt ein akzeptiertes Offer in eine Buchung um. * * Aktueller Zustand (Ticket B8 finalisiert das): * - Guards: Offer.status=accepted UND Offer.booking_id IS NULL. * - Der eigentliche Booking-Create-Aufruf hängt an `BookingService::createManual()` * aus Modul 4. Solange der nicht existiert, wirft diese Methode eine * `\RuntimeException` mit Hinweis. * * Idempotenz: Zweiter Aufruf schlägt mit {@see InvalidOfferStatusException} fehl * (booking_id ist dann schon gesetzt). * * @see docs/offers/umsetzung.md Ticket B8 */ public function convertToBooking(Offer $offer): Booking { if ($offer->status !== Offer::STATUS_ACCEPTED) { throw new InvalidOfferStatusException($offer, [Offer::STATUS_ACCEPTED], 'convertToBooking'); } if ($offer->booking_id !== null) { throw new \DomainException(sprintf( 'Offer #%d wurde bereits in Booking #%d überführt (idempotent).', $offer->id, $offer->booking_id )); } throw new \RuntimeException( 'OfferService::convertToBooking() wartet auf BookingService::createManual() ' . '(Modul 4). Wird in Ticket B8 fertiggestellt.' ); } // ───────────────────────────────────────────────────────────────────────── // intern // ───────────────────────────────────────────────────────────────────────── /** * Ermittelt den Ersteller: explizit übergeben > aktuell eingeloggter User. * Wirft, falls beides null → Service darf nie user-less angelegt werden. */ protected function resolveUserId(?int $explicit): int { if ($explicit !== null) { return $explicit; } $userId = Auth::id(); if ($userId === null) { throw new \RuntimeException( 'OfferService benötigt entweder einen expliziten $createdBy ' . 'oder einen eingeloggten User (Auth::id()).' ); } return (int) $userId; } /** * Holt `expires_at` aus dem Mail-Data-Array (Form-Feld), akzeptiert * Carbon oder parseable String; null → kein Ablauf. */ protected function extractExpires(array $mailData): ?Carbon { $raw = $mailData['expires_at'] ?? null; if ($raw === null || $raw === '') { return null; } if ($raw instanceof Carbon) { return $raw; } return Carbon::parse($raw); } }