mein-sterntours/app/Services/OfferService.php

385 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Events\Offer\OfferAccepted;
use App\Events\Offer\OfferDeclined;
use App\Events\Offer\OfferSent;
use App\Events\Offer\OfferWithdrawn;
use App\Exceptions\Offer\InvalidOfferStatusException;
use App\Exceptions\Offer\OfferNotEditableException;
use App\Models\Booking;
use App\Models\Lead;
use App\Models\Offer;
use App\Models\OfferAccessToken;
use App\Models\OfferTemplate;
use App\Models\OfferVersion;
use App\Repositories\OfferFileRepository;
use App\Repositories\OfferRepository;
use App\Repositories\OfferTemplateRepository;
use App\Repositories\OfferVersionRepository;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* Business-Logik-Schicht für das Offers-Modul.
*
* Verantwortung:
* - Orchestriert die Repositories (A3) inkl. Statusübergänge
* - Setzt Guards durch (Status, Editierbarkeit, Idempotenz)
* - Dispatcht Domain-Events (`OfferSent`, `OfferAccepted`, …). Die
* eigentliche Mail/Audit-Log-Arbeit erledigen Listener (B6).
* - Regelt atomare Multi-Schritt-Transitionen (send, accept, supersede,
* convertToBooking).
*
* Bewusst NICHT hier:
* - PDF-Rendering (B5 — eigener PdfService).
* - Mail-Composition (B6 — OfferMail-Listener).
* - HTML-Sanitizing / Eingabe-Validierung (A5 — FormRequests).
*/
class OfferService
{
public function __construct(
protected OfferRepository $offers,
protected OfferVersionRepository $versions,
protected OfferTemplateRepository $templates,
protected OfferFileRepository $files,
) {
}
// ─────────────────────────────────────────────────────────────────────────
// Anlage
// ─────────────────────────────────────────────────────────────────────────
/**
* Legt ein leeres Angebot für einen bestehenden Kontakt an, optional mit
* einer Vorlage (Template-Texte + Default-Positionen).
*/
public function createBlank(int $contactId, ?int $templateId = null, ?int $createdBy = null): Offer
{
return DB::transaction(function () use ($contactId, $templateId, $createdBy) {
$offer = $this->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<string,mixed> $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<string,mixed> $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);
}
}