385 lines
16 KiB
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);
|
|
}
|
|
}
|