Handling of event addons
This commit is contained in:
@@ -47,6 +47,13 @@ class CreateEventCommand {
|
||||
'total_max_amount' => 0,
|
||||
'support_per_person' => 0,
|
||||
'support_flat' => 0,
|
||||
// Umsatzsteuerliche Grundlage bei Anlage einfrieren (unveränderlich), damit Preisermittlung und
|
||||
// spätere Rechnungen von späteren Tenant-Änderungen unberührt bleiben.
|
||||
'tax_liable' => app('tenant')->tax_liable,
|
||||
'vat_rate' => app('tenant')->vat_rate,
|
||||
'vat_pricing_mode' => app('tenant')->vat_pricing_mode,
|
||||
'tax_exemption_reason' => app('tenant')->tax_exemption_reason,
|
||||
'tax_exemption_note' => app('tenant')->tax_exemption_note,
|
||||
]);
|
||||
|
||||
if ($event !== null) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetEventAddons;
|
||||
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SetEventAddonsCommand
|
||||
{
|
||||
public function __construct(private SetEventAddonsRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): SetEventAddonsResponse
|
||||
{
|
||||
$response = new SetEventAddonsResponse();
|
||||
|
||||
$this->request->event->addons = $this->normalize($this->request->addons);
|
||||
$this->request->event->save();
|
||||
|
||||
$response->success = true;
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert die rohe Addon-Definition: trimmt Titel/Beschreibung, parst den Preis (≥ 0), erzwingt den
|
||||
* Titel als Pflichtfeld und generiert stabile Keys (Bestandsschutz für bereits gespeicherte Buchungen).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $addons
|
||||
* @return array<int, array{key: string, title: string, description: string, price: float, flat: bool}>
|
||||
*/
|
||||
private function normalize(array $addons): array
|
||||
{
|
||||
$normalized = [];
|
||||
$usedKeys = [];
|
||||
|
||||
foreach ($addons as $addon) {
|
||||
$title = trim((string)($addon['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = Amount::fromString((string)($addon['price'] ?? '0'))->getAmount();
|
||||
if ($price < 0) {
|
||||
$price = 0.0;
|
||||
}
|
||||
|
||||
$key = $this->uniqueSlug((string)($addon['key'] ?? ''), $title, $usedKeys);
|
||||
$usedKeys[] = $key;
|
||||
|
||||
$normalized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'description' => trim((string)($addon['description'] ?? '')),
|
||||
'price' => round($price, 2),
|
||||
'flat' => (bool)($addon['flat'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert einen eindeutigen Slug: bevorzugt den bestehenden Wert, sonst aus dem Titel abgeleitet;
|
||||
* Kollisionen werden durchnummeriert.
|
||||
*
|
||||
* @param array<int, string> $used
|
||||
*/
|
||||
private function uniqueSlug(string $existing, string $title, array $used): string
|
||||
{
|
||||
$base = $existing !== '' ? $existing : Str::slug($title);
|
||||
if ($base === '') {
|
||||
$base = 'addon';
|
||||
}
|
||||
|
||||
$candidate = $base;
|
||||
$suffix = 2;
|
||||
while (in_array($candidate, $used, true)) {
|
||||
$candidate = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetEventAddons;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class SetEventAddonsRequest
|
||||
{
|
||||
public Event $event;
|
||||
|
||||
/** @var array<int, array<string, mixed>> Rohe Addon-Definition aus dem Admin-Panel. */
|
||||
public array $addons;
|
||||
|
||||
public function __construct(Event $event, array $addons)
|
||||
{
|
||||
$this->event = $event;
|
||||
$this->addons = $addons;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetEventAddons;
|
||||
|
||||
class SetEventAddonsResponse
|
||||
{
|
||||
public bool $success;
|
||||
public ?string $message;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->success = false;
|
||||
$this->message = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SetParticipationOptionsCommand
|
||||
{
|
||||
public function __construct(private SetParticipationOptionsRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): SetParticipationOptionsResponse
|
||||
{
|
||||
$response = new SetParticipationOptionsResponse();
|
||||
|
||||
$this->request->event->participation_options = $this->normalize($this->request->questions);
|
||||
$this->request->event->save();
|
||||
|
||||
$response->success = true;
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert die rohe Fragen-Definition: trimmt Labels, generiert stabile Keys/Values und verwirft
|
||||
* unvollständige Fragen (kein Label oder keine gültige Option). Keys/Values sind innerhalb ihres
|
||||
* Geltungsbereichs eindeutig, damit Antworten robust referenzieren können.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $questions
|
||||
* @return array<int, array{key: string, title: string, label: string, options: array<int, array{value: string, label: string}>}>
|
||||
*/
|
||||
private function normalize(array $questions): array
|
||||
{
|
||||
$normalized = [];
|
||||
$usedKeys = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
// Titel ist Pflicht (Überschrift/Feldbeschriftung); die Frage ist optionaler Erklärtext.
|
||||
$title = trim((string)($question['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string)($question['label'] ?? ''));
|
||||
|
||||
$options = $this->normalizeOptions($question['options'] ?? []);
|
||||
if ($options === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->uniqueSlug((string)($question['key'] ?? ''), $title, $usedKeys);
|
||||
$usedKeys[] = $key;
|
||||
|
||||
$normalized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'label' => $label,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $options
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
private function normalizeOptions(mixed $options): array
|
||||
{
|
||||
if (!is_array($options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$usedValues = [];
|
||||
|
||||
foreach ($options as $option) {
|
||||
$label = trim((string)($option['label'] ?? ''));
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->uniqueSlug((string)($option['value'] ?? ''), $label, $usedValues);
|
||||
$usedValues[] = $value;
|
||||
|
||||
$normalized[] = ['value' => $value, 'label' => $label];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert einen eindeutigen Slug: bevorzugt den bestehenden Wert (Bestandsschutz für schon gespeicherte
|
||||
* Fragen/Antworten), sonst aus dem Label abgeleitet; Kollisionen werden durchnummeriert.
|
||||
*
|
||||
* @param array<int, string> $used
|
||||
*/
|
||||
private function uniqueSlug(string $existing, string $label, array $used): string
|
||||
{
|
||||
$base = $existing !== '' ? $existing : Str::slug($label);
|
||||
if ($base === '') {
|
||||
$base = 'option';
|
||||
}
|
||||
|
||||
$candidate = $base;
|
||||
$suffix = 2;
|
||||
while (in_array($candidate, $used, true)) {
|
||||
$candidate = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class SetParticipationOptionsRequest
|
||||
{
|
||||
public Event $event;
|
||||
|
||||
/** @var array<int, array<string, mixed>> Rohe Fragen-Definition aus dem Admin-Panel. */
|
||||
public array $questions;
|
||||
|
||||
public function __construct(Event $event, array $questions)
|
||||
{
|
||||
$this->event = $event;
|
||||
$this->questions = $questions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
class SetParticipationOptionsResponse
|
||||
{
|
||||
public bool $success;
|
||||
public ?string $message;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->success = false;
|
||||
$this->message = null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ class SignUpCommand {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Teilnahmeoptionen (event-spezifische Pflicht-Auswahl) gegen die Event-Definition absichern.
|
||||
$participationOptionRows = $this->buildParticipationOptionRows();
|
||||
if ($participationOptionRows === null) {
|
||||
$response->success = false;
|
||||
$response->message = 'Bitte alle Auswahlfelder ausfüllen.';
|
||||
return $response;
|
||||
}
|
||||
|
||||
$eatingHabit = match ($this->request->eating_habit) {
|
||||
'vegan' => EatingHabit::EATING_HABIT_VEGAN,
|
||||
'vegetarian' => EatingHabit::EATING_HABIT_VEGETARIAN,
|
||||
@@ -80,8 +88,89 @@ class SignUpCommand {
|
||||
]
|
||||
);
|
||||
|
||||
$this->persistParticipationOptions($response->participant, $participationOptionRows);
|
||||
$this->persistAddons($response->participant);
|
||||
|
||||
$response->success = true;
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die gewählten Zusätze (Event-Addons) als Snapshot-Zeilen (Titel/Preis/Betrag) für die spätere
|
||||
* Rechnung. Die Beträge sind bereits in der Controller-Auflösung (`resolveAddons`) berechnet, inkl. USt.
|
||||
*/
|
||||
private function persistAddons(\App\Models\EventParticipant $participant): void
|
||||
{
|
||||
foreach ($this->request->addonItems as $item) {
|
||||
$participant->selectedAddons()->create([
|
||||
'tenant' => $this->request->event->tenant,
|
||||
'event_id' => $this->request->event->id,
|
||||
'addon_key' => $item['key'],
|
||||
'title' => $item['title'],
|
||||
'price' => $item['price'] ?? 0,
|
||||
'flat' => (bool)($item['flat'] ?? false),
|
||||
'days' => $item['days'] ?? 0,
|
||||
'amount' => $item['amount'] ?? 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die Antworten gegen die am Event definierten Teilnahmeoptionen und erzeugt die zu speichernden
|
||||
* Zeilen mit Label-Snapshots. Gibt null zurück, wenn eine Pflicht-Frage unbeantwortet oder eine Antwort
|
||||
* ungültig ist (Absicherung gegen manipulierte Payloads).
|
||||
*
|
||||
* @return array<int, array{question_key: string, question_label: string, value: string, value_label: string}>|null
|
||||
*/
|
||||
private function buildParticipationOptionRows(): ?array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->request->event->participation_options ?? [] as $question) {
|
||||
$key = $question['key'] ?? null;
|
||||
if ($key === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->request->participationOptions[$key] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$option = null;
|
||||
foreach ($question['options'] ?? [] as $candidate) {
|
||||
if (($candidate['value'] ?? null) === $value) {
|
||||
$option = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($option === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'question_key' => $key,
|
||||
'question_label' => $question['label'] ?? $key,
|
||||
'value' => $value,
|
||||
'value_label' => $option['label'] ?? $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{question_key: string, question_label: string, value: string, value_label: string}> $rows
|
||||
*/
|
||||
private function persistParticipationOptions(\App\Models\EventParticipant $participant, array $rows): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$participant->selectedOptions()->create(array_merge($row, [
|
||||
'tenant' => $this->request->event->tenant,
|
||||
'event_id' => $this->request->event->id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ class SignUpRequest {
|
||||
public Amount $amount,
|
||||
public ?string $paymentMethod,
|
||||
public array $paymentOptions = [],
|
||||
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */
|
||||
public array $participationOptions = [],
|
||||
/** Aufgelöste Zusätze (Event-Addons): je Eintrag [ key, title, price, flat, days, amount ]. */
|
||||
public array $addonItems = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,11 +72,97 @@ class UpdateParticipantCommand {
|
||||
|
||||
|
||||
$p->save();
|
||||
|
||||
if ($this->request->participationOptions !== null) {
|
||||
$this->syncParticipationOptions($p);
|
||||
}
|
||||
|
||||
if ($this->request->selectedAddons !== null) {
|
||||
$this->syncAddons($p);
|
||||
}
|
||||
|
||||
$this->response->success = true;
|
||||
$this->response->participant = $p;
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die zugeordneten Zusätze (Event-Addons) anhand der Auswahl. Bewusst **ohne Preis/Betrag**
|
||||
* (amount 0): der Teilnahmebeitrag wird in den Details ohnehin manuell gesetzt. Es wird nur der Titel als
|
||||
* Snapshot gespeichert, damit die Zuordnung sichtbar bleibt.
|
||||
*/
|
||||
private function syncAddons(EventParticipant $participant): void
|
||||
{
|
||||
$participant->selectedAddons()->delete();
|
||||
|
||||
$selection = $this->request->selectedAddons ?? [];
|
||||
|
||||
foreach ($participant->event?->addons ?? [] as $addon) {
|
||||
$key = $addon['key'] ?? null;
|
||||
if ($key === null || empty($selection[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant->selectedAddons()->create([
|
||||
'tenant' => $participant->tenant,
|
||||
'event_id' => $participant->event_id,
|
||||
'addon_key' => $key,
|
||||
'title' => $addon['title'] ?? $key,
|
||||
'price' => 0,
|
||||
'flat' => (bool)($addon['flat'] ?? false),
|
||||
'days' => 0,
|
||||
'amount' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die zugeordneten Teilnahmeoptionen anhand der übergebenen Antworten. Nachträgliche Zuordnung im
|
||||
* Back-Office ist lückenlos möglich: nur gegen die Event-Definition gültige Antworten werden gespeichert,
|
||||
* leere/entfallene Antworten werden abgeräumt. Frage-/Options-Label werden als Snapshot mitgeschrieben.
|
||||
*/
|
||||
private function syncParticipationOptions(EventParticipant $participant): void
|
||||
{
|
||||
$participant->selectedOptions()->delete();
|
||||
|
||||
$answers = $this->request->participationOptions ?? [];
|
||||
|
||||
foreach ($participant->event?->participation_options ?? [] as $question) {
|
||||
$key = $question['key'] ?? null;
|
||||
if ($key === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $answers[$key] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$option = null;
|
||||
foreach ($question['options'] ?? [] as $candidate) {
|
||||
if (($candidate['value'] ?? null) === $value) {
|
||||
$option = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($option === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string)($question['label'] ?? ''));
|
||||
|
||||
$participant->selectedOptions()->create([
|
||||
'tenant' => $participant->tenant,
|
||||
'event_id' => $participant->event_id,
|
||||
'question_key' => $key,
|
||||
'question_label' => $label !== '' ? $label : ($question['title'] ?? $key),
|
||||
'value' => $value,
|
||||
'value_label' => $option['label'] ?? $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleAmountChanges(EventParticipant $participant) {
|
||||
$this->response->amountPaid = $participant->amount_paid;
|
||||
$this->response->amountExpected = $participant->amount;
|
||||
|
||||
@@ -36,5 +36,9 @@ class UpdateParticipantRequest {
|
||||
public string $amountPaid,
|
||||
public string $amountExpected,
|
||||
public string $cocStatus,
|
||||
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. null = unverändert lassen. */
|
||||
public ?array $participationOptions = null,
|
||||
/** Gewählte Zusätze: [ addon_key => bool ]. null = unverändert lassen. */
|
||||
public ?array $selectedAddons = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user