5 Commits
Author SHA1 Message Date
th.guenther 9e9ea0cfd5 Fixed bug selecting eating habits 2026-10-04 14:36:15 +02:00
th.guenther bcebe2ebed Verbesserte EÜR-Abrechnung 2026-10-01 13:40:51 +02:00
th.guenther a43fd9cedf CoC Checks now optional 2026-09-29 14:58:21 +02:00
th.guenther a5e4e55df5 Support oft parent groups 2026-09-29 14:35:33 +02:00
th.guentherandClaude Opus 5.5 c4ca101594 endeavour: .env-Rechte, Socket-Prüfung und HTTPS-Proxy korrigiert
- deploy.sh setzt die .env auf root:mareike-docker 0640 und prüft DB_SOCKET/Socket vor dem Build
- optionale shared/deploy.conf für server-spezifische Einstellungen (z. B. MAREIKE_DB_SOCKET_DIR)
- nginx: absolute_redirect off, damit keine http://-Redirects entstehen
- Anleitung: Proxy per mod_rewrite nur unter HTTPS, keine KeyHelp-Weiterleitung, lv auf der Hauptdomain

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:30:42 +02:00
101 changed files with 2539 additions and 157 deletions

No files matched your search

+26
View File
@@ -16,6 +16,32 @@ Routing, Mails) stehen in `.ai/conventions.md` und werden hier eingebunden — *
- [Zahlungsmodule](app/EventPaymentModules/CLAUDE.md) — interface-gesteuerte Zahlungsarten (Optionen, Config,
Anmelde-Zusammenfassung, GiroCode/Fähigkeits-Interfaces, neues Modul hinzufügen).
## Local Group / Parent Group (Stamm / Bund)
Technisch heißt es **Local Group** (Mandant/Tenant) und **Parent Group** (übergeordnete Gruppe); „Stamm"/„Bund" sind nur
UI-Bezeichnungen.
- **ParentGroup** (`App\Models\ParentGroup`, Tabelle `parent_groups`, app-weit): `tenants.parent_group_id` ist Pflicht
(n:1). `is_fallback` markiert „Ohne Zuordnung" — `Tenant::booted()` setzt sie, wenn nichts angegeben ist; sie ist nie
löschbar. Andere nur, solange weder Tenant noch Anmeldung sie nutzt (`AdminParentGroupRepository::isInUse()`).
Verwaltung LV-only unter `/admin/parent-groups`; Zuordnung am Tenant im Tab „Allgemeines" und bei der Anlage.
- **Event**: `participant_group_type` (Enum `App\Enumerations\ParticipantGroupType`: `local_group` | `parent_group`),
gewählt bei der Anlage, in den Einstellungen änderbar nur ohne Anmeldungen. **Einzige Quelle für die Auswahl**:
`Event::participantGroupOptions()` — teilnehmende Stämme oder deren Bünde (distinct). Gespeichert wird
`event_participants.local_group` (Slug) **oder** `event_participants.parent_group_id`.
- **Anzeige**: `EventParticipant::participantGroupName()` → Resource-Feld `participantGroup`; `EventResource` liefert
`participantGroupType`, `participantGroupLabel`, `participantGroupOptions` (`{id, name, value}`).
- **Bezeichnungen** in `global_settings` (Model `GlobalSetting`, Repository `GlobalSettingRepository`, im Container
`scoped` — immer über `app(...)` beziehen), Singular + Plural je Ebene, gepflegt von Hauptadmins unter
`/admin/settings`. Gelesen über `ParticipantGroupType::label()/pluralLabel()/labels()`. Texte ohne Artikel formulieren
(„Bitte bei „{label}" eine Auswahl treffen."), weil der Artikel vom Wort abhängt.
## eFZ-Prüfung pro Veranstaltung
`events.coc_check_required` (Default `true`, Checkbox in `CommonSettings.vue`, Resource-Feld `cocCheckRequired`). Ist
sie aus, liefert `CertificateOfConductionCheckCommand` ohne API-Aufruf `not_required`, und `SignUpCommand` belegt
`efz_status` mit `not_required` vor. Wirkt nur auf neue Anmeldungen, bestehende Status bleiben.
## Umsatzsteuer / Steuerinformationen
Rechtlich korrekter Begriff ist **Umsatzsteuer (USt)**, nicht MwSt. Ziel: tagesbasierte Anmelde-Rechnungen („Teilnahme
@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
use App\Enumerations\ParticipantGroupType;
use App\Models\ParentGroup;
class CreateParentGroupAction
{
public function __construct(private CreateParentGroupRequest $request)
{
}
public function execute(): CreateParentGroupResponse
{
$response = new CreateParentGroupResponse();
$name = trim($this->request->name);
if ($name === '') {
$response->message = 'Bitte einen Namen angeben.';
return $response;
}
$response->parentGroup = ParentGroup::create([
'name' => $name,
'is_fallback' => false,
]);
$response->success = true;
$response->message = sprintf('%s „%s" wurde angelegt.', ParticipantGroupType::ParentGroup->label(), $name);
return $response;
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
class CreateParentGroupRequest
{
public function __construct(
public string $name,
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
use App\Models\ParentGroup;
class CreateParentGroupResponse
{
public bool $success = false;
public string $message = '';
public ?ParentGroup $parentGroup = null;
}
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Enumerations\ParticipantGroupType;
use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod;
use App\Models\Tenant;
@@ -16,10 +17,17 @@ class CreateTenantAction
{
$response = new CreateTenantResponse();
if ($this->request->parentGroup === null) {
$response->message = sprintf('Bitte bei „%s" eine Auswahl treffen.', ParticipantGroupType::ParentGroup->label());
return $response;
}
$tenant = Tenant::create([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
'parent_group_id' => $this->request->parentGroup->id,
'email' => '',
'email_finance' => '',
'account_name' => '',
@@ -46,7 +54,7 @@ class CreateTenantAction
}
$response->success = true;
$response->message = 'Stamm wurde angelegt.';
$response->message = sprintf('%s wurde angelegt.', ParticipantGroupType::LocalGroup->label());
$response->slug = $tenant->slug;
return $response;
@@ -2,12 +2,16 @@
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Models\ParentGroup;
class CreateTenantRequest
{
public function __construct(
public string $name,
public string $slug,
public string $url,
/** Pflicht; null heißt, die übermittelte ID war unbekannt. */
public ?ParentGroup $parentGroup,
) {
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
class DeleteParentGroupAction
{
public function __construct(private DeleteParentGroupRequest $request)
{
}
public function execute(): DeleteParentGroupResponse
{
$response = new DeleteParentGroupResponse();
// Die Fallback-Gruppe nimmt Mandanten ohne Zuordnung auf und muss deshalb bestehen bleiben.
if ($this->request->parentGroup->is_fallback) {
$response->message = sprintf('„%s" kann nicht gelöscht werden.', $this->request->parentGroup->name);
return $response;
}
if ($this->request->inUse) {
$response->message = sprintf(
'„%s" ist noch zugeordnet und kann deshalb nicht gelöscht werden.',
$this->request->parentGroup->name,
);
return $response;
}
$this->request->parentGroup->delete();
$response->success = true;
$response->message = sprintf('„%s" wurde gelöscht.', $this->request->parentGroup->name);
return $response;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
use App\Models\ParentGroup;
class DeleteParentGroupRequest
{
public function __construct(
public ParentGroup $parentGroup,
/** Ob die Gruppe noch von einem Mandanten oder einer Anmeldung verwendet wird. */
public bool $inUse,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
class DeleteParentGroupResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
class RenameParentGroupAction
{
public function __construct(private RenameParentGroupRequest $request)
{
}
public function execute(): RenameParentGroupResponse
{
$response = new RenameParentGroupResponse();
$name = trim($this->request->name);
if ($name === '') {
$response->message = 'Bitte einen Namen angeben.';
return $response;
}
// Umbenennen ist jederzeit möglich: Mandanten und Anmeldungen verweisen über die ID.
$this->request->parentGroup->update(['name' => $name]);
$response->success = true;
$response->message = 'Der Name wurde gespeichert.';
return $response;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
use App\Models\ParentGroup;
class RenameParentGroupRequest
{
public function __construct(
public ParentGroup $parentGroup,
public string $name,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
class RenameParentGroupResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
use App\Models\GlobalSetting;
class UpdateGlobalSettingsAction
{
public function __construct(private UpdateGlobalSettingsRequest $request)
{
}
public function execute(): UpdateGlobalSettingsResponse
{
$response = new UpdateGlobalSettingsResponse();
$values = [
GlobalSetting::LOCAL_GROUP_LABEL => trim($this->request->localGroupLabel),
GlobalSetting::LOCAL_GROUP_LABEL_PLURAL => trim($this->request->localGroupLabelPlural),
GlobalSetting::PARENT_GROUP_LABEL => trim($this->request->parentGroupLabel),
GlobalSetting::PARENT_GROUP_LABEL_PLURAL => trim($this->request->parentGroupLabelPlural),
];
// Eine leere Bezeichnung ließe Überschriften und Auswahlfelder ohne Beschriftung zurück.
if (in_array('', $values, true)) {
$response->message = 'Bitte alle Bezeichnungen ausfüllen.';
return $response;
}
$this->request->globalSettings->setMany($values);
$response->success = true;
$response->message = 'Einstellungen wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
use App\Repositories\GlobalSettingRepository;
class UpdateGlobalSettingsRequest
{
public function __construct(
public GlobalSettingRepository $globalSettings,
public string $localGroupLabel,
public string $localGroupLabelPlural,
public string $parentGroupLabel,
public string $parentGroupLabelPlural,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
class UpdateGlobalSettingsResponse
{
public bool $success = false;
public string $message = '';
}
@@ -2,6 +2,8 @@
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
use App\Enumerations\ParticipantGroupType;
class UpdateTenantGeneralAction
{
public function __construct(private UpdateTenantGeneralRequest $request)
@@ -12,10 +14,17 @@ class UpdateTenantGeneralAction
{
$response = new UpdateTenantGeneralResponse();
if ($this->request->parentGroup === null) {
$response->message = sprintf('Bitte bei „%s" eine Auswahl treffen.', ParticipantGroupType::ParentGroup->label());
return $response;
}
$this->request->tenant->update([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
'parent_group_id' => $this->request->parentGroup->id,
]);
$response->success = true;
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
use App\Models\ParentGroup;
use App\Models\Tenant;
class UpdateTenantGeneralRequest
@@ -11,6 +12,8 @@ class UpdateTenantGeneralRequest
public string $name,
public string $slug,
public string $url,
/** Pflicht; null heißt, die übermittelte ID war unbekannt. */
public ?ParentGroup $parentGroup,
) {
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GlobalSettingsGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'groupLabels' => ParticipantGroupType::labels(),
]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class GlobalSettingsPageController extends CommonController
{
public function __invoke(Request $request): Response
{
return new InertiaProvider('Admin/GlobalSettings', [])->render();
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsAction;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GlobalSettingsUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
globalSettings: $this->globalSettings,
localGroupLabel: (string) $request->input('localGroup'),
localGroupLabelPlural: (string) $request->input('localGroupPlural'),
parentGroupLabel: (string) $request->input('parentGroup'),
parentGroupLabelPlural: (string) $request->input('parentGroupPlural'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupAction;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupCreateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$response = new CreateParentGroupAction(new CreateParentGroupRequest(
name: (string) $request->input('name'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupAction;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupDeleteController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$parentGroup = $this->adminParentGroups->findById($id);
if ($parentGroup === null) {
return response()->json(['status' => 'error', 'message' => 'Eintrag nicht gefunden.'], 404);
}
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest(
parentGroup: $parentGroup,
inUse: $this->adminParentGroups->isInUse($parentGroup),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupListApiController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$parentGroups = $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
'isFallback' => $parentGroup->is_fallback,
'tenantCount' => $parentGroup->tenants_count,
'deletable' => !$parentGroup->is_fallback && !$this->adminParentGroups->isInUse($parentGroup),
]);
return response()->json(['parentGroups' => $parentGroups]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class ParentGroupListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
return new InertiaProvider('Admin/ParentGroupList', [
'groupLabels' => ParticipantGroupType::labels(),
])->render();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupAction;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupUpdateController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$parentGroup = $this->adminParentGroups->findById($id);
if ($parentGroup === null) {
return response()->json(['status' => 'error', 'message' => 'Eintrag nicht gefunden.'], 404);
}
$response = new RenameParentGroupAction(new RenameParentGroupRequest(
parentGroup: $parentGroup,
name: (string) $request->input('name'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -16,6 +16,7 @@ class TenantCreateController extends CommonController
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
parentGroup: $this->adminParentGroups->findById((int) $request->input('parent_group_id')),
));
$response = $action->execute();
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,12 @@ class TenantGeneralGetController extends CommonController
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
'parent_group_id' => $tenant->parent_group_id,
'parentGroups' => $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
]),
'parentGroupLabel' => ParticipantGroupType::ParentGroup->label(),
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/general',
]);
}
@@ -19,6 +19,7 @@ class TenantGeneralUpdateController extends CommonController
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
parentGroup: $this->adminParentGroups->findById((int) $request->input('parent_group_id')),
));
$response = $action->execute();
@@ -18,9 +18,17 @@ class TenantListApiController extends CommonController
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
'parentGroup' => $tenant->parentGroup?->name,
];
});
return response()->json(['tenants' => $mapped]);
// Auswahl für das Anlage-Formular (Pflichtangabe).
$parentGroups = $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
'isFallback' => $parentGroup->is_fallback,
]);
return response()->json(['tenants' => $mapped, 'parentGroups' => $parentGroups]);
}
}
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
@@ -11,7 +12,9 @@ class TenantListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/TenantList', []);
$inertiaProvider = new InertiaProvider('Admin/TenantList', [
'groupLabels' => ParticipantGroupType::labels(),
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Repositories;
use App\Models\ParentGroup;
use Illuminate\Database\Eloquent\Collection;
class AdminParentGroupRepository
{
/** Alle Parent Groups, alphabetisch mit „Ohne Zuordnung" am Ende, mit der Zahl zugeordneter Mandanten. */
public function all(): Collection
{
return ParentGroup::withCount('tenants')
->ordered()
->get();
}
public function findById(int $id): ?ParentGroup
{
return ParentGroup::find($id);
}
/**
* Ob die Parent Group noch verwendet wird -- von einem Mandanten oder von einer Anmeldung. Anmeldungen sind
* mandantengebunden; gezählt wird hier bewusst über alle Mandanten hinweg.
*/
public function isInUse(ParentGroup $parentGroup): bool
{
return $parentGroup->tenants()->exists()
|| $parentGroup->eventParticipants()->withoutGlobalScopes()->exists();
}
}
+19
View File
@@ -5,6 +5,8 @@ use App\Domains\Admin\Controllers\DocumentAssetUpdateController;
use App\Domains\Admin\Controllers\DocumentTemplatesGetController;
use App\Domains\Admin\Controllers\DocumentTemplatesPreviewController;
use App\Domains\Admin\Controllers\DocumentTemplatesUpdateController;
use App\Domains\Admin\Controllers\GlobalSettingsGetController;
use App\Domains\Admin\Controllers\GlobalSettingsUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
@@ -15,6 +17,10 @@ use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantTaxGetController;
use App\Domains\Admin\Controllers\ManagedTenantTaxUpdateController;
use App\Domains\Admin\Controllers\ParentGroupCreateController;
use App\Domains\Admin\Controllers\ParentGroupDeleteController;
use App\Domains\Admin\Controllers\ParentGroupListApiController;
use App\Domains\Admin\Controllers\ParentGroupUpdateController;
use App\Domains\Admin\Controllers\TenantContactGetController;
use App\Domains\Admin\Controllers\TenantContactUpdateController;
use App\Domains\Admin\Controllers\TenantCreateController;
@@ -76,6 +82,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/assets', DocumentAssetUpdateController::class);
Route::delete('/assets/{name}', DocumentAssetDeleteController::class);
});
// Bezeichnungen der Gruppen-Ebenen gelten ebenfalls app-weit.
Route::prefix('api/v1/admin/settings')->group(function () {
Route::get('/', GlobalSettingsGetController::class);
Route::post('/', GlobalSettingsUpdateController::class);
});
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
@@ -97,5 +109,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/tax', ManagedTenantTaxUpdateController::class);
});
});
Route::prefix('api/v1/admin/parent-groups')->group(function () {
Route::get('/list', ParentGroupListApiController::class);
Route::post('/create', ParentGroupCreateController::class);
Route::post('/{id}', ParentGroupUpdateController::class)->whereNumber('id');
Route::delete('/{id}', ParentGroupDeleteController::class)->whereNumber('id');
});
});
});
+4
View File
@@ -2,6 +2,8 @@
use App\Domains\Admin\Controllers\AdminDashboardController;
use App\Domains\Admin\Controllers\DocumentTemplatesPageController;
use App\Domains\Admin\Controllers\GlobalSettingsPageController;
use App\Domains\Admin\Controllers\ParentGroupListPageController;
use App\Domains\Admin\Controllers\TenantEditPageController;
use App\Domains\Admin\Controllers\TenantListPageController;
use App\Domains\Admin\Controllers\TenantPageController;
@@ -21,11 +23,13 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
// App-weit gültige Dokumentvorlagen -- nur für Hauptadministrator*innen.
Route::middleware(MainAdminRoleMiddleware::class)->group(function () {
Route::get('/document-templates', DocumentTemplatesPageController::class);
Route::get('/settings', GlobalSettingsPageController::class);
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
Route::get('/tenants', TenantListPageController::class);
Route::get('/tenants/{slug}', TenantEditPageController::class);
Route::get('/parent-groups', ParentGroupListPageController::class);
});
});
});
+116
View File
@@ -0,0 +1,116 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const { request } = useAjax()
const form = ref({
localGroup: '',
localGroupPlural: '',
parentGroup: '',
parentGroupPlural: '',
})
onMounted(async () => {
const response = await request('/api/v1/admin/settings')
Object.assign(form.value, response?.groupLabels ?? {})
})
async function save() {
const response = await request('/api/v1/admin/settings', {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<AdminAppLayout title="Einstellungen">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<h3>Bezeichnungen der Gruppen</h3>
<p class="hint">
Gelten für alle Mandanten: in Anmeldungen, Teilnehmendenlisten, Exporten und in der Verwaltung.
</p>
<table class="data-table">
<thead>
<tr>
<th></th>
<th>Einzahl</th>
<th>Mehrzahl</th>
</tr>
</thead>
<tbody>
<tr>
<th>Gruppe (Mandant)</th>
<td><input type="text" v-model="form.localGroup" class="form-input" placeholder="z. B. Stamm" /></td>
<td><input type="text" v-model="form.localGroupPlural" class="form-input" placeholder="z. B. Stämme" /></td>
</tr>
<tr>
<th>Übergeordnete Gruppe</th>
<td><input type="text" v-model="form.parentGroup" class="form-input" placeholder="z. B. Bund" /></td>
<td><input type="text" v-model="form.parentGroupPlural" class="form-input" placeholder="z. B. Bünde" /></td>
</tr>
</tbody>
</table>
<button class="btn-save" @click="save">Speichern</button>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.hint {
color: #6b7280;
font-size: 0.9rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-save {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroupPlural: 'Stämme', parentGroup: 'Bund', parentGroupPlural: 'Bünde' }),
},
})
const { request } = useAjax()
const parentGroups = ref([])
const newName = ref('')
const showCreate = ref(false)
// ID des Eintrags, der gerade umbenannt wird, samt Entwurf des neuen Namens.
const editingId = ref(null)
const editName = ref('')
onMounted(async () => {
await loadParentGroups()
})
async function loadParentGroups() {
const response = await request('/api/v1/admin/parent-groups/list')
parentGroups.value = response?.parentGroups ?? []
}
async function createParentGroup() {
const response = await request('/api/v1/admin/parent-groups/create', {
method: 'POST',
body: { name: newName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
newName.value = ''
showCreate.value = false
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
}
}
function startEditing(parentGroup) {
editingId.value = parentGroup.id
editName.value = parentGroup.name
}
async function saveName(parentGroup) {
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'POST',
body: { name: editName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
editingId.value = null
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
async function deleteParentGroup(parentGroup) {
if (!confirm('„' + parentGroup.name + '" wirklich löschen?')) {
return
}
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'DELETE',
})
if (response && response.status === 'success') {
toast.success(response.message)
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Löschen')
}
}
</script>
<template>
<AdminAppLayout :title="props.groupLabels.parentGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="parent-group-table">
<thead>
<tr>
<th>Name</th>
<th>Zugeordnete {{ props.groupLabels.localGroupPlural }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="parentGroup in parentGroups" :key="parentGroup.id">
<td>
<input v-if="editingId === parentGroup.id" type="text" v-model="editName" class="form-input"
@keyup.enter="saveName(parentGroup)" />
<span v-else>{{ parentGroup.name }}</span>
</td>
<td>{{ parentGroup.tenantCount }}</td>
<td class="actions">
<template v-if="editingId === parentGroup.id">
<button class="btn-save" @click="saveName(parentGroup)">Speichern</button>
<button class="btn-cancel" @click="editingId = null">Abbrechen</button>
</template>
<template v-else>
<button class="btn-edit" @click="startEditing(parentGroup)">Umbenennen</button>
<button v-if="parentGroup.deletable" class="btn-delete" @click="deleteParentGroup(parentGroup)">Löschen</button>
</template>
</td>
</tr>
</tbody>
</table>
<p class="hint">
Löschen ist nur möglich, solange keine {{ props.groupLabels.localGroupPlural }} und keine Anmeldungen
zugeordnet sind. „Ohne Zuordnung" bleibt immer bestehen.
</p>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.parentGroup }})</h3>
<input type="text" v-model="newName" class="form-input" placeholder="Name"
@keyup.enter="createParentGroup" />
<div class="btn-group">
<button class="btn-save" @click="createParentGroup">Anlegen</button>
<button class="btn-cancel" @click="showCreate = false">Abbrechen</button>
</div>
</div>
</div>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.parent-group-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.parent-group-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.parent-group-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.hint {
margin-top: 12px;
color: #6b7280;
font-size: 0.9rem;
}
.create-form {
margin-top: 15px;
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
}
.create-form h3 {
margin: 0 0 15px 0;
color: #374151;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-create, .btn-save, .btn-cancel, .btn-edit, .btn-delete {
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-create, .btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.btn-create:hover, .btn-edit:hover {
background-color: #163a7a;
}
.btn-save {
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
.btn-cancel {
background-color: #e5e7eb;
color: #374151;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-delete {
background-color: #dc2626;
color: #ffffff;
}
.btn-delete:hover {
background-color: #b91c1c;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
@@ -17,8 +17,16 @@ const form = ref({
name: props.data.name ?? '',
slug: props.data.slug ?? '',
url: props.data.url ?? '',
parent_group_id: props.data.parent_group_id ?? null,
})
// Übergeordnete Gruppe (z. B. „Bund") -- Pflichtangabe, Auswahl aus der LV-seitig gepflegten Liste.
const parentGroups = props.data.parentGroups ?? []
const parentGroupLabel = props.data.parentGroupLabel ?? 'Bund'
const parentGroupName = computed(() =>
parentGroups.find(g => g.id === form.value.parent_group_id)?.name ?? '—'
)
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenants/' + props.data.slug + '/general'
const response = await request(saveUrl, {
@@ -39,6 +47,7 @@ async function save() {
<div v-if="!editing">
<table class="data-table">
<tr><th>Name:</th><td>{{ form.name }}</td></tr>
<tr><th>{{ parentGroupLabel }}:</th><td>{{ parentGroupName }}</td></tr>
<tr><th>Slug:</th><td>{{ form.slug }}</td></tr>
<tr><th>URL:</th><td>{{ form.url }}</td></tr>
<tr><th>Status:</th><td>
@@ -54,6 +63,14 @@ async function save() {
<th>Name:</th>
<td><input type="text" v-model="form.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ parentGroupLabel }}:</th>
<td>
<select v-model="form.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug:</th>
<td><input type="text" v-model="form.slug" class="form-input" /></td>
+31 -5
View File
@@ -5,6 +5,13 @@ import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroup: 'Stamm', localGroupPlural: 'Stämme', parentGroup: 'Bund' }),
},
})
const { request } = useAjax()
function openTenant(slug) {
@@ -12,12 +19,16 @@ function openTenant(slug) {
}
const tenants = ref([])
const parentGroups = ref([])
const showCreate = ref(false)
const createForm = ref({
const emptyForm = () => ({
name: '',
slug: '',
url: '',
// Pflichtangabe; vorbelegt mit „Ohne Zuordnung", sobald die Liste geladen ist.
parent_group_id: parentGroups.value.find(g => g.isFallback)?.id ?? null,
})
const createForm = ref(emptyForm())
onMounted(async () => {
await loadTenants()
@@ -33,6 +44,11 @@ async function loadTenants() {
})
const data = await response.json()
tenants.value = data.tenants
parentGroups.value = data.parentGroups ?? []
if (createForm.value.parent_group_id === null) {
createForm.value.parent_group_id = parentGroups.value.find(g => g.isFallback)?.id ?? null
}
}
async function createTenant() {
@@ -44,7 +60,7 @@ async function createTenant() {
if (response && response.status === 'success') {
toast.success(response.message)
showCreate.value = false
createForm.value = { name: '', slug: '', url: '' }
createForm.value = emptyForm()
await loadTenants()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
@@ -53,12 +69,13 @@ async function createTenant() {
</script>
<template>
<AdminAppLayout title="Stämme">
<AdminAppLayout :title="props.groupLabels.localGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="tenant-table">
<thead>
<tr>
<th>Name</th>
<th>{{ props.groupLabels.parentGroup }}</th>
<th>Slug</th>
<th>URL</th>
<th>Status</th>
@@ -67,6 +84,7 @@ async function createTenant() {
<tbody>
<tr v-for="tenant in tenants" :key="tenant.slug" class="tenant-row" @click="openTenant(tenant.slug)">
<td>{{ tenant.name }}</td>
<td>{{ tenant.parentGroup ?? '—' }}</td>
<td>{{ tenant.slug }}</td>
<td>{{ tenant.url }}</td>
<td>
@@ -79,15 +97,23 @@ async function createTenant() {
</table>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Stamm anlegen</button>
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Stamm anlegen</h3>
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</h3>
<table class="form-table">
<tr>
<th>Name</th>
<td><input type="text" v-model="createForm.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ props.groupLabels.parentGroup }}</th>
<td>
<select v-model="createForm.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug</th>
<td><input type="text" v-model="createForm.slug" class="form-input" /></td>
@@ -12,6 +12,12 @@ class CertificateOfConductionCheckCommand {
public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse();
// Verlangt die Veranstaltung kein eFZ, entfällt die Prüfung -- ohne Aufruf der Prüf-API.
if (!$this->request->participant->event()->first()?->coc_check_required) {
$response->status = EfzStatus::EFZ_STATUS_NOT_REQUIRED;
return $response;
}
// Der Stamm ist optional (die Kurzanmeldung erhebt ihn nicht, in der Verwaltung kann er entfernt werden).
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()?->name ?? '');
@@ -62,6 +62,7 @@ class CreateEventCommand {
'vat_pricing_mode' => $tenant->vat_pricing_mode,
'tax_exemption_reason' => $tenant->tax_exemption_reason,
'tax_exemption_note' => $tenant->tax_exemption_note,
'participant_group_type' => $this->request->participantGroupType->value,
// Den Event-Teil der Rechnungsnummer einfrieren: Tenant-Präfix und Startdatum sind später
// änderbar, eine herausgegebene Rechnung würde sonst zu einer anderen Nummer gehören.
//
@@ -2,6 +2,7 @@
namespace App\Domains\Event\Actions\CreateEvent;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use DateTime;
@@ -21,8 +22,11 @@ class CreateEventRequest {
public bool $payPerDay;
public ?string $street;
public ?string $houseNumber;
/** Wonach bei der Anmeldung gefragt wird: Local Group oder Parent Group. */
public ParticipantGroupType $participantGroupType;
public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay, ?string $street = null, ?string $houseNumber = null) {
public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay, ?string $street = null, ?string $houseNumber = null, ParticipantGroupType $participantGroupType = ParticipantGroupType::LocalGroup) {
$this->participantGroupType = $participantGroupType;
$this->name = $name;
$this->location = $location;
$this->postalCode = $postalCode;
@@ -5,6 +5,7 @@ namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
use App\Enumerations\ParticipationType;
use App\Models\CostUnit;
use App\Models\Event;
use App\Models\Invoice;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\Repositories\CostUnitRepository;
use App\ValueObjects\Amount;
@@ -52,8 +53,9 @@ class CreateIncomeSurplusStatementCommand
$eventData = $this->event->toResource()->toArray(new Request());
$income = $this->buildIncome($eventData, $otherIncome);
$expenses = $this->buildExpenses($costUnit);
$donations = $this->buildExpenses($costUnit, donationsOnly: true);
$income = $this->buildIncome($eventData, $otherIncome, $donations['total']);
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
@@ -62,6 +64,7 @@ class CreateIncomeSurplusStatementCommand
'createdAt' => new \DateTime()->format('d.m.Y'),
'income' => $income,
'expenses' => $expenses,
'donations' => $donations,
'result' => $result,
'money' => self::money(...),
])->render();
@@ -70,6 +73,7 @@ class CreateIncomeSurplusStatementCommand
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
$response->income = $income;
$response->expenses = $expenses;
$response->donations = $donations;
$response->result = $result;
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
@@ -87,7 +91,7 @@ class CreateIncomeSurplusStatementCommand
*
* @return array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}
*/
private function buildIncome(array $eventData, float $otherIncome): array
private function buildIncome(array $eventData, float $otherIncome, Amount $donations): array
{
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
// hereingekommen ist, nicht von wem.
@@ -118,6 +122,8 @@ class CreateIncomeSurplusStatementCommand
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
],
// Gespendete Auslagen: als Ausgabe ihres Typs gebucht, hier die Gegenbuchung.
['name' => 'Spenden', 'amount' => $donations],
];
$categories = [
@@ -134,14 +140,17 @@ class CreateIncomeSurplusStatementCommand
/**
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
*
* Mit `$donationsOnly` nur die gespendeten Auslagen in derselben Gliederung -- für die Anlage „Spenden"
* und die Summe unter den Förderungen.
*
* @return array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array{number: string, date: string, purpose: string, amount: Amount}>}>, total: Amount}
*/
private function buildExpenses(CostUnit $costUnit): array
private function buildExpenses(CostUnit $costUnit, bool $donationsOnly = false): array
{
$groups = [];
$total = new Amount(0, 'Euro');
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
foreach ($this->costUnits->groupExpensesByType($costUnit, $donationsOnly) as $group) {
$rows = [];
foreach ($group['invoices'] as $invoice) {
@@ -150,7 +159,7 @@ class CreateIncomeSurplusStatementCommand
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
// Ohne die Anmerkung: dort steht, was die Kassenwart*in beim Korrigieren notiert hat,
// und das gehört auf den Beleg, nicht in den Zweck.
'purpose' => $invoice->purposeText(),
'purpose' => self::purpose($invoice),
'amount' => Amount::fromString($invoice->amount),
];
}
@@ -167,6 +176,25 @@ class CreateIncomeSurplusStatementCommand
return ['groups' => $groups, 'total' => $total];
}
/**
* Der Zweck eines Belegs für die Anlage.
*
* Erfasster oder abgeleiteter Zahlungsgrund wie in der Beleg-Übersicht. Nur wenn es keinen gibt --
* ältere Abrechnungen ohne jede Zweckangabe --, steht dort ersatzweise, wem was erstattet wurde. Bewusst
* hier und nicht in {@see Invoice::purposeText()}: Dort würde der Ersatztext als erfasster Zahlungsgrund
* erscheinen und beim Korrigieren als Änderung protokolliert.
*/
private static function purpose(Invoice $invoice): string
{
$purpose = $invoice->purposeText();
if (trim($purpose) !== '') {
return $purpose;
}
return trim('Auslagenerstattung ' . $invoice->contact_name);
}
/**
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
*
@@ -29,5 +29,12 @@ class CreateIncomeSurplusStatementResponse
*/
public array $expenses = [];
/**
* Die gespendeten Auslagen, gegliedert wie die Ausgaben -- die Gegenbuchung zur Zeile „Spenden".
*
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
*/
public array $donations = [];
public ?Amount $result = null;
}
@@ -4,6 +4,8 @@ namespace App\Domains\Event\Actions\ShortSignUp;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\ValueObjects\Age;
/**
@@ -53,18 +55,21 @@ class ShortSignUpCommand {
return $response;
}
// Stamm: bei genau einem teilnehmenden Stamm wird dieser gesetzt, bei mehreren muss gewählt werden,
// ohne teilnehmende Stämme bleibt die Zuordnung leer.
$localGroups = $this->request->event->localGroups;
$localGroup = null;
// Gruppe (Local oder Parent Group, je nach Veranstaltung): bei genau einer Option wird diese gesetzt, bei
// mehreren muss gewählt werden, ohne Optionen bleibt die Zuordnung leer.
$groupOptions = $this->request->event->participantGroupOptions();
$participantGroup = null;
if ($localGroups->count() === 1) {
$localGroup = $localGroups->first();
} elseif ($localGroups->count() > 1) {
$localGroup = $localGroups->firstWhere('id', $this->request->localGroupId);
if ($groupOptions->count() === 1) {
$participantGroup = $groupOptions->first();
} elseif ($groupOptions->count() > 1) {
$participantGroup = $groupOptions->firstWhere('id', $this->request->localGroupId);
if ($localGroup === null) {
$response->message = 'Bitte einen Stamm auswählen.';
if ($participantGroup === null) {
$response->message = sprintf(
'Bitte bei „%s" eine Auswahl treffen.',
$this->request->event->participantGroupType()->label(),
);
return $response;
}
}
@@ -87,7 +92,7 @@ class ShortSignUpCommand {
lastname: $this->request->lastname,
nickname: $this->request->nickname,
participationType: $participationType,
localGroup: $localGroup,
localGroup: $participantGroup instanceof Tenant ? $participantGroup : null,
birthday: $this->request->birthday,
address_1: self::PLACEHOLDER,
address_2: null,
@@ -123,6 +128,7 @@ class ShortSignUpCommand {
addonItems: [],
feeType: $this->request->feeType,
siblingReduction: false,
parentGroup: $participantGroup instanceof ParentGroup ? $participantGroup : null,
);
$signUpResponse = new SignUpCommand($signUpRequest)->execute();
@@ -37,11 +37,7 @@ class SignUpCommand {
return $response;
}
$eatingHabit = match ($this->request->eating_habit) {
'vegan' => EatingHabit::EATING_HABIT_VEGAN,
'vegetarian' => EatingHabit::EATING_HABIT_VEGETARIAN,
default => EatingHabit::EATING_HABIT_OMNIVOR,
};
$eatingHabit = $this->resolveEatingHabit();
$participantAge = new Age($this->request->birthday);
@@ -61,6 +57,7 @@ class SignUpCommand {
'fee_type' => $this->request->feeType,
'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup?->slug,
'parent_group_id' => $this->request->parentGroup?->id,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2,
@@ -94,7 +91,9 @@ class SignUpCommand {
'payment_purpose' => $this->request->event->name . ' - Beitrag ' . $this->request->firstname . ' ' . $this->request->lastname,
'payment_method' => $this->request->paymentMethod,
'payment_options' => $paymentOptions,
'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED,
'efz_status' => $participantAge->isfullAged() && $this->request->event->coc_check_required
? EfzStatus::EFZ_STATUS_NOT_CHECKED
: EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]
);
@@ -108,6 +107,21 @@ class SignUpCommand {
return $response;
}
/**
* Ernährungsweise auflösen. Übernommen wird nur ein Slug, den die Veranstaltung anbietet; ohne Angabe (z. B. bei
* der Kurzanmeldung) oder bei einem nicht angebotenen Wert gilt "Omnivor".
*/
private function resolveEatingHabit(): string
{
$slug = $this->request->eating_habit;
if ($slug !== '' && $this->request->event->eatingHabits()->where('slug', $slug)->exists()) {
return $slug;
}
return EatingHabit::EATING_HABIT_OMNIVOR;
}
/**
* Badeerlaubnis auflösen. Volljährige brauchen keine Erlaubnis. Für Minderjährige gilt die getroffene Auswahl;
* fehlt sie -- weil die Veranstaltung die Badeerlaubnis gar nicht abfragt oder keine Wahl getroffen wurde --,
@@ -3,6 +3,7 @@
namespace App\Domains\Event\Actions\SignUp;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use DateTime;
@@ -55,6 +56,8 @@ class SignUpRequest {
public ?string $feeType = null,
/** Ob die 50-%-Geschwisterermäßigung in `amount` eingerechnet ist. */
public bool $siblingReduction = false,
/** Parent Group -- nur bei Veranstaltungen, die danach statt nach der Local Group fragen. */
public ?ParentGroup $parentGroup = null,
) {
}
}
@@ -2,6 +2,8 @@
namespace App\Domains\Event\Actions\UpdateEvent;
use App\Repositories\EventParticipantRepository;
class UpdateEventCommand {
public UpdateEventRequest $request;
public function __construct(UpdateEventRequest $request) {
@@ -26,6 +28,15 @@ class UpdateEventCommand {
$this->request->event->registration_allowed = $this->request->registrationAllowed;
$this->request->event->short_registration = $this->request->shortRegistration;
$this->request->event->swimming_permission_required = $this->request->swimmingPermissionRequired;
$this->request->event->coc_check_required = $this->request->cocCheckRequired;
// Umstellen zwischen Local und Parent Group nur ohne Anmeldungen -- sonst stünden in einer Veranstaltung
// beide Arten von Antworten nebeneinander.
if ($this->request->participantGroupType !== null
&& !new EventParticipantRepository()->existsForEvent($this->request->event)) {
$this->request->event->participant_group_type = $this->request->participantGroupType->value;
}
$this->request->event->save();
$this->request->event->resetAllowedEatingHabits();
@@ -2,6 +2,7 @@
namespace App\Domains\Event\Actions\UpdateEvent;
use App\Enumerations\ParticipantGroupType;
use App\Models\Event;
use App\ValueObjects\Amount;
use DateTime;
@@ -21,13 +22,18 @@ class UpdateEventRequest {
public bool $registrationAllowed;
public bool $shortRegistration;
public bool $swimmingPermissionRequired;
public bool $cocCheckRequired;
public Amount $flatSupport;
public Amount $supportPerPerson;
public array $contributingLocalGroups;
public array $eatingHabits;
/** Wonach bei der Anmeldung gefragt wird; null = unverändert. Greift nur, solange es keine Anmeldungen gibt. */
public ?ParticipantGroupType $participantGroupType;
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, bool $shortRegistration = false, bool $swimmingPermissionRequired = true, ?string $street = null, ?string $houseNumber = null)
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, bool $shortRegistration = false, bool $swimmingPermissionRequired = true, ?string $street = null, ?string $houseNumber = null, ?ParticipantGroupType $participantGroupType = null, bool $cocCheckRequired = true)
{
$this->participantGroupType = $participantGroupType;
$this->cocCheckRequired = $cocCheckRequired;
$this->event = $event;
$this->eventName = $eventName;
$this->eventLocation = $eventLocation;
@@ -31,8 +31,7 @@ class UpdateParticipantCommand {
$p->address_2 = $this->request->address_2;
$p->postcode = $this->request->postcode;
$p->city = $this->request->city;
// Leerer String würde als Fremdschlüssel scheitern -- kein Stamm heißt null.
$p->local_group = $this->request->localgroup ?: null;
$this->applyParticipantGroup($p);
$p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$p->email_1 = $this->request->email_1;
$p->phone_1 = $this->request->phone_1;
@@ -87,6 +86,29 @@ class UpdateParticipantCommand {
return $this->response;
}
/**
* Übernimmt die gewählte Gruppe -- je nach Veranstaltung die Local Group (Slug) oder die Parent Group (ID).
* Angenommen wird nur, was die Veranstaltung zur Auswahl stellt; leer heißt keine Angabe (null, ein leerer
* String würde als Fremdschlüssel scheitern).
*/
private function applyParticipantGroup(EventParticipant $participant): void
{
$event = $participant->event;
$value = trim((string) $this->request->participantGroup);
if ($event->asksForParentGroup()) {
$participant->parent_group_id = $value === ''
? null
: $event->participantGroupOptions()->firstWhere('id', (int) $value)?->id;
return;
}
$participant->local_group = $value === ''
? null
: $event->participantGroupOptions()->firstWhere('slug', $value)?->slug;
}
/**
* 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
@@ -14,8 +14,11 @@ class UpdateParticipantRequest {
public ?string $address_2,
public string $postcode,
public string $city,
/** Stamm-Slug; leer/null bei Anmeldungen, die keinen Stamm erfasst haben (Kurzanmeldung). */
public ?string $localgroup,
/**
* Gewählte Gruppe: Slug der Local Group bzw. ID der Parent Group, je nachdem, wonach die Veranstaltung
* fragt. Leer/null = keine Angabe.
*/
public ?string $participantGroup,
public string $birthday,
public string $email_1,
public string $phone_1,
@@ -9,6 +9,7 @@ use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\SetCostUnit\SetCostUnitCommand;
use App\Domains\Event\Actions\SetCostUnit\SetCostUnitRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Providers\InertiaProvider;
use App\Resources\EventResource;
@@ -28,6 +29,7 @@ class CreateController extends CommonController {
'participationFeeType' => $this->tenant->slug === 'lv' ?
ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED :
ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY,
'participantGroupTypes' => ParticipantGroupType::options(),
])->render();
}
@@ -58,7 +60,8 @@ class CreateController extends CommonController {
$request->input('eventIban'),
$payPerDay,
$request->input('eventStreet'),
$request->input('eventHouseNumber')
$request->input('eventHouseNumber'),
ParticipantGroupType::fromStored($request->input('eventParticipantGroupType')),
);
$wasSuccessful = false;
@@ -14,6 +14,7 @@ use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventRequest;
use App\Domains\Event\Actions\UpdateManagers\UpdateManagersCommand;
use App\Domains\Event\Actions\UpdateManagers\UpdateManagersRequest;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Providers\InertiaProvider;
@@ -27,7 +28,10 @@ use Illuminate\Http\Response;
class DetailsController extends CommonController {
public function __invoke(string $eventId) {
$event = $this->events->getByIdentifier($eventId);
return new InertiaProvider('Event/Details', ['event' => $event])->render();
return new InertiaProvider('Event/Details', [
'event' => $event,
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
}
public function summary(int $eventId, Request $request) : JsonResponse {
@@ -65,6 +69,8 @@ class DetailsController extends CommonController {
(bool)$request->input('swimmingPermissionRequired', true),
$request->input('street'),
$request->input('houseNumber'),
ParticipantGroupType::tryFrom((string) $request->input('participantGroupType')),
(bool)$request->input('cocCheckRequired', true),
);
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
@@ -185,6 +191,7 @@ class DetailsController extends CommonController {
'rows' => $participants,
'kitchenRequirements' => $kitchenOverview,
'participantsForKitchenList' => $this->eventParticipants->getParticipantsWithIntolerances($event, $request),
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
$pdf = PdfGenerateAndDownloadProvider::fromHtml(
@@ -208,6 +215,9 @@ class DetailsController extends CommonController {
$csv = view('csvs.' . $listType, [
'event' => $event->name,
'rows' => $participants,
// Die Anschriftsspalten beschreiben immer die Local Group, die Gruppenspalte die gewählte Gruppe.
'localGroupLabel' => ParticipantGroupType::LocalGroup->label(),
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
return response($csv, 200, [
@@ -220,7 +230,7 @@ class DetailsController extends CommonController {
$event = $this->events->getByIdentifier($eventId);
switch ($listType) {
case 'by-local-group':
$participants = $this->eventParticipants->groupByLocalGroup($event, $request);
$participants = $this->eventParticipants->groupByParticipantGroup($event, $request);
break;
case 'by-participation-group':
$participants = $this->eventParticipants->groupByParticipationType($event, $request);
@@ -12,7 +12,7 @@ class ByGroupController extends CommonController
$recipients = [];
switch ($groupType) {
case 'by-local-group':
$participants = $this->eventParticipants->groupByLocalGroup($event, $request, $request->input('groupName'));
$participants = $this->eventParticipants->groupByParticipantGroup($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break;
case 'by-participation-group':
@@ -22,7 +22,7 @@ class ParticipantUpdateController extends CommonController {
address_2: $request->input('address_2'),
postcode: $request->input('postcode'),
city: $request->input('city'),
localgroup: $request->input('localgroup'),
participantGroup: $request->input('participantGroup'),
birthday: $request->input('birthday'),
email_1: $request->input('email_1'),
phone_1: $request->input('phone_1'),
@@ -7,6 +7,7 @@ use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConducti
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\Providers\DoubleCheckEventRegistrationProvider;
use App\Providers\InertiaProvider;
@@ -65,7 +66,7 @@ class SignupController extends CommonController {
$inertiaProvider = new InertiaProvider('Event/Signup', [
'event' => $event,
'availableEvents' => $availableEvents,
'localGroups' => $event['contributingLocalGroups'],
'localGroups' => $event['participantGroupOptions'] ?? [],
'participantData' => $participantData,
]);
return $inertiaProvider->render();
@@ -111,6 +112,15 @@ class SignupController extends CommonController {
$addonResolution = $eventResource->resolveAddons($selectedAddonKeys, $arrival, $departure);
$amount->addAmount($addonResolution['total']);
// Gewählt wird eine der Optionen der Veranstaltung (Local oder Parent Group); anderes wird abgelehnt.
$participantGroup = $event->participantGroupOptions()->firstWhere('id', (int)($registrationData['localGroup'] ?? 0));
if ($participantGroup === null) {
return response()->json([
'status' => 'error',
'message' => sprintf('Bitte bei „%s" eine Auswahl treffen.', $event->participantGroupType()->label()),
], 422);
}
$signupRequest = new SignUpRequest(
$event,
$registrationData['userId'] ?? null,
@@ -118,7 +128,7 @@ class SignupController extends CommonController {
$registrationData['nachname'],
$registrationData['pfadiname'],
$registrationData['participationType'],
Tenant::findOrFail($registrationData['localGroup']),
$participantGroup instanceof Tenant ? $participantGroup : null,
\DateTime::createFromFormat('Y-m-d', $registrationData['geburtsdatum']),
$registrationData['address1'],
$registrationData['address2'],
@@ -133,7 +143,7 @@ class SignupController extends CommonController {
$registrationData['intolerances'],
$registrationData['medikamente'],
$tetanusVaccination,
$registrationData['essgewohnheit'],
$registrationData['eatingHabit'] ?? '',
$registrationData['badeerlaubnis'],
$registrationData['first_aid'],
$registrationData['foto']['socialmedia'],
@@ -153,6 +163,7 @@ class SignupController extends CommonController {
$addonResolution['items'],
$registrationData['beitrag'],
$siblingReduction,
$participantGroup instanceof ParentGroup ? $participantGroup : null,
);
$signupCommand = new SignUpCommand($signupRequest);
+12
View File
@@ -15,6 +15,7 @@
'eventIban': String,
"eventPayPerDay": Boolean,
"participationFeeType": String,
"participantGroupTypes": {type: Array, default: () => []},
})
const errors = reactive({})
@@ -35,6 +36,7 @@
eventIban: props.eventIban ? props.eventIban : '',
eventPayPerDay: props.eventPayPerDay ? props.eventPayPerDay : false,
eventParticipationFeeType: props.participationFeeType ? props.participationFeeType : 'fixed',
eventParticipantGroupType: 'local_group',
});
watch(
@@ -159,6 +161,7 @@
eventIban: formData.eventIban,
eventPayPerDay: formData.eventPayPerDay,
eventParticipationFeeType: formData.eventParticipationFeeType,
eventParticipantGroupType: formData.eventParticipantGroupType,
}
});
@@ -256,6 +259,15 @@
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Bei der Anmeldung abfragen</th>
<td class="height-50">
<select v-model="formData.eventParticipantGroupType" class="width-half-full">
<option v-for="type in props.participantGroupTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Preiserhöhung nach Early-Bird-Phase</th>
+2 -1
View File
@@ -10,6 +10,7 @@ import Overview from "./Partials/Overview.vue";
const props = defineProps({
event: Object,
participantGroupLabel: {type: String, default: 'Stamm'},
})
const tabs = [
@@ -24,7 +25,7 @@ const tabs = [
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/all',
},
{
title: 'Teilis nach Stamm',
title: 'Teilis nach ' + props.participantGroupLabel,
component: ParticipantsList,
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-local-group',
},
@@ -16,8 +16,13 @@ const emit = defineEmits(['close'])
const dynmicProps = reactive({
localGroups: [],
eatingHabits:[],
participantGroupTypes: [],
localGroupLabelPlural: 'Stämme',
});
// Umstellen zwischen Local und Parent Group nur, solange es keine Anmeldungen gibt.
const participantGroupTypeLocked = (props.event.totalParticipantCount ?? 0) > 0
const contributingLocalGroups = ref([])
const eatingHabits = ref([]);
@@ -39,8 +44,10 @@ const emit = defineEmits(['close'])
registrationAllowed: props.event.registrationAllowed,
shortRegistration: props.event.shortRegistration,
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
cocCheckRequired: props.event.cocCheckRequired,
flatSupport: props.event.flatSupportEdit,
supportPerson: props.event.supportPersonIndex,
participantGroupType: props.event.participantGroupType ?? 'local_group',
})
onMounted(async () => {
@@ -72,10 +79,12 @@ const emit = defineEmits(['close'])
registrationAllowed: formData.registrationAllowed,
shortRegistration: formData.shortRegistration,
swimmingPermissionRequired: formData.swimmingPermissionRequired,
cocCheckRequired: formData.cocCheckRequired,
flatSupport: formData.flatSupport,
supportPerson: formData.supportPerson,
contributingLocalGroups: contributingLocalGroups.value,
eatingHabits: eatingHabits.value,
participantGroupType: formData.participantGroupType,
}
})
@@ -173,6 +182,18 @@ const emit = defineEmits(['close'])
</td>
</tr>
<tr>
<th>Bei der Anmeldung abfragen</th>
<td>
<select v-model="formData.participantGroupType" class="width-full" :disabled="participantGroupTypeLocked">
<option v-for="type in dynmicProps.participantGroupTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
<span v-if="participantGroupTypeLocked" style="font-size: 0.8rem; color: #6b7280;">
Nicht mehr änderbar, da bereits Anmeldungen vorliegen.
</span>
</td>
</tr>
<tr>
<th>Mindestalter für Alkoholkonsum</th>
<td>
@@ -215,6 +236,16 @@ const emit = defineEmits(['close'])
</span>
</td>
</tr>
<tr>
<td colspan="2">
<input type="checkbox" v-model="formData.cocCheckRequired" id="cocCheckRequired" />
<label for="cocCheckRequired">Erweitertes Führungszeugnis (eFZ) prüfen</label><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Ist die Prüfung deaktiviert, wird das eFZ bei neuen Anmeldungen als „Nicht erforderlich" hinterlegt.
</span>
</td>
</tr>
</table>
@@ -223,7 +254,7 @@ const emit = defineEmits(['close'])
<div class="right">
<table>
<tr>
<th>Teilnehmende Stämme</th>
<th>Teilnehmende {{ dynmicProps.localGroupLabelPlural }}</th>
</tr>
<tr v-for="localGroup in dynmicProps.localGroups">
<td>
@@ -42,7 +42,7 @@ const form = reactive({
address_2: '',
postcode: '',
city: '',
localgroup: '',
participantGroup: '',
birthday: '',
email_1: '',
phone_1: '',
@@ -71,6 +71,9 @@ const form = reactive({
// Teilnahmeoptionen des Events (Definition mit Titel/Frage/Optionen).
const participationQuestions = computed(() => staticProps.event?.participationOptions ?? []);
// Bezeichnung der gewählten Gruppe (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = computed(() => staticProps.event?.participantGroupLabel ?? 'Stamm');
// Zusatzoptionen (Event-Addons) des Events.
const eventAddons = computed(() => staticProps.event?.addons ?? []);
@@ -95,7 +98,7 @@ watch(
form.address_2 = participant?.address_2 ?? '';
form.postcode = participant?.postcode ?? '';
form.city = participant?.city ?? '';
form.localgroup = participant?.local_group ?? '';
form.participantGroup = participant?.participantGroupValue ?? '';
form.birthday = participant?.birthdayDate ?? '';
form.email_1 = participant?.email_1 ?? '';
form.phone_1 = participant?.phone_1 ?? '';
@@ -229,12 +232,12 @@ function saveParticipant() {
</tr>
<tr>
<th>Stamm</th>
<th>{{ localGroupLabel }}</th>
<td>
<span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span>
<select v-else v-model="form.localgroup">
<option value="">Kein Stamm</option>
<option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option>
<span v-if="!staticProps.editMode">{{ props.participant.participantGroup }}</span>
<select v-else v-model="form.participantGroup">
<option value="">Keine Angabe</option>
<option v-for="group in staticProps.event.participantGroupOptions" :key="group.id" :value="group.value">{{ group.name }}</option>
</select>
</td>
</tr>
@@ -162,7 +162,7 @@ async function saveParticipant(formData) {
document.getElementById('participant-' + data.participant.identifier + '-fullname').innerHTML = data.participant.fullname;
document.getElementById('participant-' + data.participant.identifier + '-birthday').innerText = data.participant.birthday;
document.getElementById('participant-' + data.participant.identifier + '-age').innerText = data.participant.age;
document.getElementById('participant-' + data.participant.identifier + '-localgroup').innerText = data.participant.localgroup;
document.getElementById('participant-' + data.participant.identifier + '-localgroup').innerText = data.participant.participantGroup;
document.getElementById('participant-' + data.participant.identifier + '-arrival').innerText = data.participant.arrival;
document.getElementById('participant-' + data.participant.identifier + '-departure').innerText = data.participant.departure;
document.getElementById('participant-' + data.participant.identifier + '-email_1').innerText = data.participant.email_1;
@@ -641,7 +641,7 @@ function mailToGroup(groupKey) {
<tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'">
<td colspan="5" class="pl-meta">
<label :id="'participant-' + participant.identifier +'-localgroup'">
{{ participant?.localgroup ?? "-" }}
{{ participant?.participantGroup ?? "-" }}
</label> |
<strong> Anreise: </strong>
<label :id="'participant-' + participant.identifier +'-arrival'">
@@ -13,10 +13,12 @@ export function useShortSignupForm(event, participantData) {
// Gibt es nur eine Zahlungsart, wird sie vorausgewählt und der Zahlungsschritt entfällt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Bei genau einem teilnehmenden Stamm wird dieser gesetzt, ohne danach zu fragen; erst ab zwei
// Stämmen erscheint das Auswahlfeld.
const localGroups = event.contributingLocalGroups ?? []
// Auswahl je nach Veranstaltung: teilnehmende Stämme oder deren Bünde. Bei genau einer Option wird diese
// gesetzt, ohne danach zu fragen; erst ab zwei Optionen erscheint das Auswahlfeld.
const localGroups = event.participantGroupOptions ?? []
const needsLocalGroup = localGroups.length > 1
// Vorbelegung mit dem Stamm des eingeloggten Nutzers nur, wenn er zur Auswahl gehört (bei Bund-Abfrage nie).
const preselectedGroup = localGroups.some(g => g.id === participantData.localGroup) ? participantData.localGroup : '-1'
const formData = reactive({
userId: participantData.id,
@@ -26,7 +28,7 @@ export function useShortSignupForm(event, participantData) {
geburtsdatum: participantData.birthday ?? '',
email_1: participantData.email ?? '',
telefon_1: participantData.phone ?? '',
localGroup: localGroups.length === 1 ? localGroups[0].id : (participantData.localGroup ?? '-1'),
localGroup: localGroups.length === 1 ? localGroups[0].id : preselectedGroup,
ansprechpartner: '',
email_2: '',
allergien: participantData.allergies ?? '',
@@ -11,6 +11,9 @@ const props = defineProps({
})
const emit = defineEmits(['next'])
// Bezeichnung der Auswahl (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = props.event?.participantGroupLabel ?? 'Stamm'
const errors = reactive({
vorname: '',
nachname: '',
@@ -46,7 +49,7 @@ const next = () => {
}
if (props.needsLocalGroup && (!props.formData.localGroup || props.formData.localGroup === '-1')) {
errors.localGroup = 'Bitte einen Stamm auswählen.'
errors.localGroup = 'Bitte bei „' + localGroupLabel + '" eine Auswahl treffen.'
hasError = true
}
@@ -122,7 +125,7 @@ const next = () => {
</td>
</tr>
<tr v-if="needsLocalGroup">
<td>Stamm:</td>
<td>{{ localGroupLabel }}:</td>
<td>
<select v-model="formData.localGroup">
<option value="-1">Bitte wählen</option>
@@ -43,9 +43,9 @@ const selectedParticipationOptions = computed(() =>
}).filter(entry => entry.value !== '')
)
// Der Stamm steht entweder fest (genau ein teilnehmender Stamm) oder wurde auf Seite 1 gewählt.
// Die Gruppe (Stamm oder Bund) steht entweder fest (genau eine Option) oder wurde auf Seite 1 gewählt.
const localGroupName = computed(() =>
(props.event.contributingLocalGroups ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
(props.event.participantGroupOptions ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
)
const permissionName = (list, slug) =>
@@ -84,7 +84,7 @@ const canSubmit = computed(() =>
<td>{{ formData.telefon_1 }}</td>
</tr>
<tr v-if="localGroupName">
<td>Stamm:</td>
<td>{{ event.participantGroupLabel ?? 'Stamm' }}:</td>
<td>{{ localGroupName }}</td>
</tr>
@@ -92,7 +92,7 @@ const steps = [
<form @submit.prevent="submit">
<StepAge v-if="currentStep === 1" :event="event" @next="goToStep" />
<StepContactPerson v-if="currentStep === 2" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepPersonalData v-if="currentStep === 3" :formData="formData" :localGroups="localGroups" @next="goToStep" @back="goToStep" />
<StepPersonalData v-if="currentStep === 3" :formData="formData" :event="event" :localGroups="localGroups" @next="goToStep" @back="goToStep" />
<StepArrival v-if="currentStep === 4" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepRegistrationMode v-if="currentStep === 5" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepAddons v-if="currentStep === 6" :formData="formData" :event="event" :selectedAddons="selectedAddons" @next="goToStep" @back="goToStep" />
@@ -13,8 +13,13 @@ export function useSignupForm(event, participantData) {
// Für die Aktion freigeschaltete Zahlungsmethoden; ist genau eine aktiv, wird sie vorausgewählt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Vorbelegung mit der Ernährungsweise aus dem Profil, sofern die Veranstaltung sie anbietet, sonst die erste Option.
const eatingHabitSlugs = (event.eatingHabits ?? []).map(h => h.data.slug)
const formData = reactive({
eatingHabit: 'EATING_HABIT_VEGAN',
eatingHabit: eatingHabitSlugs.includes(participantData.eating_habit)
? participantData.eating_habit
: (eatingHabitSlugs[0] ?? ''),
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
paymentOptions: {},
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
@@ -24,7 +29,10 @@ export function useSignupForm(event, participantData) {
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
localGroup: participantData.localGroup ?? '-1',
// Vorbelegung mit dem Stamm des eingeloggten Nutzers nur, wenn er zur Auswahl gehört (bei Bund-Abfrage nie).
localGroup: (event.participantGroupOptions ?? []).some(g => g.id === participantData.localGroup)
? participantData.localGroup
: '-1',
geburtsdatum: participantData.birthday ?? '',
address1: participantData.address_1 ?? '',
address2: participantData.address_2 ?? '',
@@ -50,7 +58,6 @@ export function useSignupForm(event, participantData) {
intolerances: participantData.intolerances ?? '',
medikamente: participantData.medications ?? '',
tetanusVaccination: participantData.tetanusVaccination ?? '',
essgewohnheit: 'vegetarian',
anmerkungen: '',
summary_information_correct: false,
summary_accept_terms: false,
@@ -2,9 +2,12 @@
import {reactive} from "vue";
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
const props = defineProps({ formData: Object, localGroups: Array })
const props = defineProps({ formData: Object, event: Object, localGroups: Array })
const emit = defineEmits(['next', 'back'])
// Bezeichnung der Auswahl (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = props.event?.participantGroupLabel ?? 'Stamm'
const errors = reactive({
vorname: '',
nachname: '',
@@ -46,7 +49,7 @@ const next = () => {
}
if (props.formData.localGroup === '-1') {
errors.localGroup = 'Bitte den Stamm auswählen.'
errors.localGroup = 'Bitte bei „' + localGroupLabel + '" eine Auswahl treffen.'
hasError = true
}
@@ -90,7 +93,7 @@ const next = () => {
<tr><td>Nachname:</td><td><input type="text" v-model="props.formData.nachname" /><ErrorText :message="errors.nachname"/></td></tr>
<tr><td>Pfadiname:</td><td><input type="text" v-model="props.formData.pfadiname" /></td></tr>
<tr>
<td>Stamm:</td>
<td>{{ localGroupLabel }}:</td>
<td>
<select v-model="props.formData.localGroup">
<option value="-1">Bitte wählen</option>
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Enumerations;
use App\Models\GlobalSetting;
use App\Repositories\GlobalSettingRepository;
/**
* Wonach eine Veranstaltung bei der Anmeldung fragt: nach der Local Group (Mandant, im UI z. B. „Stamm") oder nach
* der Parent Group (übergeordnete Gruppe, im UI z. B. „Bund"). Die Bezeichnungen kommen aus den globalen
* Einstellungen.
*/
enum ParticipantGroupType: string
{
case LocalGroup = 'local_group';
case ParentGroup = 'parent_group';
public function label(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL, 'Stamm'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL, 'Bund'),
};
}
public function pluralLabel(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL_PLURAL, 'Stämme'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL_PLURAL, 'Bünde'),
};
}
/**
* Optionen für das Frontend: [['value' => ..., 'label' => ...], ...].
*
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
static fn (self $type): array => [
'value' => $type->value,
'label' => $type->label(),
],
self::cases(),
);
}
/** Typ zu einem gespeicherten Wert; Unbekanntes fällt auf die Local Group zurück. */
public static function fromStored(?string $value): self
{
return self::tryFrom((string) $value) ?? self::LocalGroup;
}
/**
* Alle Bezeichnungen fürs Frontend (Shared Prop).
*
* @return array{localGroup: string, localGroupPlural: string, parentGroup: string, parentGroupPlural: string}
*/
public static function labels(): array
{
return [
'localGroup' => self::LocalGroup->label(),
'localGroupPlural' => self::LocalGroup->pluralLabel(),
'parentGroup' => self::ParentGroup->label(),
'parentGroupPlural' => self::ParentGroup->pluralLabel(),
];
}
private static function setting(string $key, string $default): string
{
return app(GlobalSettingRepository::class)->get($key, $default);
}
}
+34
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Casts\AmountCast;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\RelationModels\EventPaymentMethods;
@@ -13,6 +14,7 @@ use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* @property string $tenant
@@ -49,7 +51,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property boolean $archived
* @property boolean $short_registration
* @property boolean $swimming_permission_required
* @property boolean $coc_check_required
* @property string|null $invoice_key
* @property string $participant_group_type
*/
class Event extends InstancedModel
{
@@ -99,8 +103,10 @@ class Event extends InstancedModel
'short_registration',
'swimming_permission_required',
'coc_check_required',
'invoice_key',
'participant_group_type',
];
protected $casts = [
@@ -133,6 +139,7 @@ class Event extends InstancedModel
'short_registration' => 'boolean',
'swimming_permission_required' => 'boolean',
'coc_check_required' => 'boolean',
];
/**
@@ -186,6 +193,33 @@ class Event extends InstancedModel
return $this->belongsToMany(Tenant::class, 'event_local_groups', 'event_id', 'local_group_id');
}
public function participantGroupType() : ParticipantGroupType {
return ParticipantGroupType::fromStored($this->participant_group_type);
}
public function asksForParentGroup() : bool {
return $this->participantGroupType() === ParticipantGroupType::ParentGroup;
}
/**
* Was bei der Anmeldung zur Auswahl steht: die teilnehmenden Local Groups oder -- fragt die Veranstaltung nach der
* Parent Group -- deren Parent Groups, jede nur einmal (alphabetisch, „Ohne Zuordnung" zuletzt).
* Einzige Quelle für Anzeige und Prüfung der Auswahl.
*
* @return Collection<int, Tenant>|Collection<int, ParentGroup>
*/
public function participantGroupOptions() : Collection {
$localGroups = $this->localGroups()->with('parentGroup')->get();
if (!$this->asksForParentGroup()) {
return $localGroups;
}
return ParentGroup::sortForDisplay(
$localGroups->pluck('parentGroup')->filter()->unique('id')
);
}
public function eatingHabits() : BelongsToMany{
return $this->belongsToMany(EatingHabit::class, 'event_allowed_eating_habits', 'event_id', 'eating_habit_id');
}
+17
View File
@@ -38,6 +38,7 @@ class EventParticipant extends InstancedModel
'fee_type',
'sibling_reduction',
'local_group',
'parent_group_id',
'birthday',
'address_1',
@@ -143,6 +144,22 @@ class EventParticipant extends InstancedModel
return $this->belongsTo(Tenant::class, 'local_group', 'slug');
}
/** Parent Group -- nur bei Veranstaltungen gesetzt, die danach statt nach der Local Group fragen. */
public function parentGroup()
{
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
/** Anzeigename der bei der Anmeldung gewählten Gruppe, je nachdem, wonach die Veranstaltung fragt. */
public function participantGroupName(Event $event) : string
{
if ($event->asksForParentGroup()) {
return $this->parentGroup?->name ?? 'Keine Angabe';
}
return $this->localGroup?->name ?? 'Nicht im LV';
}
public function participationType()
{
return $this->belongsTo(ParticipationType::class, 'participation_type', 'slug');
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
/**
* App-weite Einstellung als Schlüssel/Wert-Paar (z. B. die Bezeichnungen „Stamm"/„Bund").
* Nicht mandantengebunden; gelesen wird über {@see \App\Repositories\GlobalSettingRepository}.
*
* @property string $key
* @property string $value
*/
class GlobalSetting extends CommonModel
{
public const string LOCAL_GROUP_LABEL = 'local_group_label';
public const string LOCAL_GROUP_LABEL_PLURAL = 'local_group_label_plural';
public const string PARENT_GROUP_LABEL = 'parent_group_label';
public const string PARENT_GROUP_LABEL_PLURAL = 'parent_group_label_plural';
protected $table = 'global_settings';
protected $primaryKey = 'key';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'key',
'value',
];
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Übergeordnete Gruppe (Parent Group, im UI z. B. „Bund"). Mehrere Mandanten (Local Groups) gehören zur selben
* Parent Group. Nicht mandantengebunden -- die Liste gilt app-weit.
*
* @property int $id
* @property string $name
* @property boolean $is_fallback
*/
class ParentGroup extends CommonModel
{
protected $table = 'parent_groups';
protected $fillable = [
'name',
'is_fallback',
];
protected $casts = [
'is_fallback' => 'boolean',
];
/** Die Gruppe „Ohne Zuordnung" -- Vorgabe für Mandanten, denen keine Gruppe zugewiesen wurde. */
public static function fallback() : self {
return self::where('is_fallback', true)->orderBy('id')->firstOrFail();
}
/** Anzeigereihenfolge: alphabetisch, „Ohne Zuordnung" (Fallback) immer zuletzt. */
public function scopeOrdered(Builder $query) : Builder {
return $query->orderBy('is_fallback')->orderBy('name');
}
/**
* Dieselbe Reihenfolge wie {@see scopeOrdered()} für bereits geladene Gruppen.
*
* @param Collection<int, self> $parentGroups
* @return Collection<int, self>
*/
public static function sortForDisplay(Collection $parentGroups) : Collection {
return $parentGroups
->sort(static fn (self $a, self $b): int =>
[$a->is_fallback, mb_strtolower($a->name)] <=> [$b->is_fallback, mb_strtolower($b->name)])
->values();
}
public function tenants() : HasMany {
return $this->hasMany(Tenant::class, 'parent_group_id');
}
public function eventParticipants() : HasMany {
return $this->hasMany(EventParticipant::class, 'parent_group_id');
}
}
+9
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Scopes\CommonModel;
use App\Support\Text;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $slug
@@ -35,6 +36,7 @@ use App\Support\Text;
* @property string|null $tax_exemption_reason
* @property string|null $tax_exemption_note
* @property string $vat_pricing_mode
* @property int $parent_group_id
*/
class Tenant extends CommonModel
{
@@ -46,9 +48,15 @@ class Tenant extends CommonModel
{
static::creating(static function (self $tenant): void {
$tenant->invoice_prefix ??= strtoupper((string) $tenant->slug);
// Die Parent Group ist Pflicht; ohne Angabe landet der Mandant bei „Ohne Zuordnung".
$tenant->parent_group_id ??= ParentGroup::fallback()->id;
});
}
public function parentGroup() : BelongsTo {
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
public static function getTempDirectory() : string {
return currentTenant()->slug . '/temp-data/';
}
@@ -95,6 +103,7 @@ class Tenant extends CommonModel
'tax_number',
'vat_id',
'invoice_prefix',
'parent_group_id',
];
protected $casts = [
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Repositories\GlobalSettingRepository;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
@@ -12,7 +13,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Eine Instanz je Request, damit die Einstellungen nur einmal gelesen werden.
$this->app->scoped(GlobalSettingRepository::class);
}
/**
+5
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\TravelReason;
@@ -45,6 +46,8 @@ class GlobalDataProvider {
'currentEvent' => $this->getCurrentEventData(),
'canAccessAdmin' => $canAccessAdmin,
'isMainAdmin' => $isMainAdmin,
// Bezeichnungen der Gruppen-Ebenen (z. B. Stamm/Stämme, Bund/Bünde) aus den globalen Einstellungen.
'groupLabels' => ParticipantGroupType::labels(),
]);
}
@@ -222,6 +225,8 @@ class GlobalDataProvider {
[
'localGroups' => Tenant::where(['is_active_local_group' => true])->get(),
'eatingHabits' => EatingHabit::all(),
'participantGroupTypes' => ParticipantGroupType::options(),
'localGroupLabelPlural' => ParticipantGroupType::LocalGroup->pluralLabel(),
'paymentMethods' => AvailablePaymentMethod::where('active', true)->get()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
]);
+21 -5
View File
@@ -172,9 +172,13 @@ class CostUnitRepository {
* damit immer dieselbe Gliederung, und eine fehlende Zeile lässt sich nicht mit einer vergessenen
* verwechseln.
*
* Gespendete Auslagen zählen hier mit: Für die Einnahmen-Überschuss-Rechnung ist die Spende eine Ausgabe
* ihres Typs und zugleich eine Einnahme. Mit `$donationsOnly` liefert die Methode genau diesen Spendenanteil
* in derselben Gliederung -- die Gegenbuchungen lassen sich so ihrer Ausgabenkategorie zuordnen.
*
* @return array<string, array{type: InvoiceType, invoices: array<int, Invoice>, sum: Amount}>
*/
public function groupExpensesByType(CostUnit $costUnit) : array {
public function groupExpensesByType(CostUnit $costUnit, bool $donationsOnly = false) : array {
$groups = [];
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
@@ -186,7 +190,11 @@ class CostUnitRepository {
}
foreach ($costUnit->invoices()->orderBy('invoice_number')->get() as $invoice) {
if (!self::countsAsExpense($invoice) || !isset($groups[$invoice->type])) {
if (
!self::isBooked($invoice) ||
!isset($groups[$invoice->type]) ||
($donationsOnly && !$invoice->donation)
) {
continue;
}
@@ -201,14 +209,22 @@ class CostUnitRepository {
* Ob ein Beleg als Ausgabe zählt.
*
* Abgelehnt und gelöscht sind keine Ausgabe, weil kein Geld fließt. Dasselbe gilt für eine gespendete
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus.
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus. Die Einnahmen-Überschuss-
* Rechnung sieht das anders und bucht die Spende als Ausgabe und Einnahme, siehe
* {@see self::groupExpensesByType()}.
*/
private static function countsAsExpense(Invoice $invoice) : bool {
return self::isBooked($invoice) && !$invoice->donation;
}
/**
* Ob ein Beleg überhaupt gebucht ist -- abgelehnt und gelöscht sind es nicht.
*/
private static function isBooked(Invoice $invoice) : bool {
return !in_array($invoice->status, [
InvoiceStatus::INVOICE_STATUS_DENIED,
InvoiceStatus::INVOICE_STATUS_DELETED,
], true)
&& !$invoice->donation;
], true);
}
public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount {
@@ -73,12 +73,18 @@ class EventParticipantRepository {
->first();
}
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
/** Ob es zur Veranstaltung schon Anmeldungen gibt -- abgemeldete eingeschlossen. */
public function existsForEvent(Event $event) : bool {
return EventParticipant::where('event_id', $event->id)->exists();
}
/** Gruppiert nach der bei der Anmeldung gewählten Gruppe (Local oder Parent Group, je nach Veranstaltung). */
public function groupByParticipantGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
foreach ($allParticipants as $participant) {
if ($filter === null || $participant['localgroup'] === $filter) {
$participants[$participant['localgroup']][] = $participant;
if ($filter === null || $participant['participantGroup'] === $filter) {
$participants[$participant['participantGroup']][] = $participant;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Repositories;
use App\Models\GlobalSetting;
/**
* Zugriff auf die app-weiten Einstellungen. Im Container als `scoped` registriert -- über
* `app(GlobalSettingRepository::class)` beziehen, dann werden die Werte nur einmal pro Request gelesen
* (die Bezeichnungen werden je Teili-Zeile einer Liste gebraucht).
*/
class GlobalSettingRepository {
/** @var array<string, string>|null */
private ?array $values = null;
public function get(string $key, string $default) : string {
$value = $this->all()[$key] ?? null;
return ($value === null || trim($value) === '') ? $default : $value;
}
/** @param array<string, string> $values */
public function setMany(array $values) : void {
foreach ($values as $key => $value) {
GlobalSetting::updateOrCreate(['key' => $key], ['value' => $value]);
}
$this->values = null;
}
/** @return array<string, string> */
private function all() : array {
return $this->values ??= GlobalSetting::pluck('value', 'key')->all();
}
}
+6 -1
View File
@@ -51,7 +51,12 @@ class EventParticipantResource extends JsonResource
'unregisteredAt' => $this->resource->unregistered_at?->format('d.m.Y'),
'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
// Die bei der Anmeldung gewählte Gruppe: Local Group oder Parent Group, je nach Veranstaltung.
'participantGroup' => $this->resource->participantGroupName($event),
// Wert für die Auswahl in der Verwaltung: Slug der Local Group bzw. ID der Parent Group.
'participantGroupValue' => $event->asksForParentGroup()
? ($this->resource->parent_group_id !== null ? (string) $this->resource->parent_group_id : '')
: ($this->resource->local_group ?? ''),
// Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
// lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
+15
View File
@@ -6,6 +6,7 @@ use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Enumerations\VatPricingMode;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Support\DateRange;
use App\ValueObjects\Amount;
use DateTime;
@@ -84,6 +85,7 @@ class EventResource extends JsonResource{
// Steuert im Frontend, welcher Anmelde-Wizard gerendert wird, und ob die Badeerlaubnis abgefragt wird.
$returnArray['shortRegistration'] = (bool)$this->event->short_registration;
$returnArray['swimmingPermissionRequired'] = (bool)$this->event->swimming_permission_required;
$returnArray['cocCheckRequired'] = (bool)$this->event->coc_check_required;
$returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true;
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
@@ -149,6 +151,19 @@ class EventResource extends JsonResource{
$returnArray['contributingLocalGroups'] = $this->event->localGroups
->map(fn ($localGroup) => new LocalGroupResource($localGroup)->toArray($request))
->toArray();
// Wonach bei der Anmeldung gefragt wird (Local oder Parent Group), mit Bezeichnung und Auswahl.
// `id` wird bei der Anmeldung übermittelt, `value` ist der in der Verwaltung gepflegte Wert
// (Slug der Local Group bzw. ID der Parent Group).
$returnArray['participantGroupType'] = $this->event->participantGroupType()->value;
$returnArray['participantGroupLabel'] = $this->event->participantGroupType()->label();
$returnArray['participantGroupOptions'] = $this->event->participantGroupOptions()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'value' => $group instanceof ParentGroup ? (string) $group->id : $group->slug,
])
->values()
->toArray();
$returnArray['eatingHabits'] = $this->event->eatingHabits()->get()->map(
fn($eatingHabit) => new EatingHabitResource($eatingHabit))->toArray();
+6
View File
@@ -2,6 +2,7 @@
namespace App\Scopes;
use App\Domains\Admin\Repositories\AdminParentGroupRepository;
use App\Domains\Admin\Repositories\AdminTenantRepository;
use App\Domains\Admin\Repositories\AdminUserRepository;
use App\Models\Tenant;
@@ -10,6 +11,7 @@ use App\Repositories\CostUnitRepository;
use App\Repositories\EstimatesRepository;
use App\Repositories\EventParticipantRepository;
use App\Repositories\EventRepository;
use App\Repositories\GlobalSettingRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
@@ -31,6 +33,8 @@ abstract class CommonController {
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
protected AdminParentGroupRepository $adminParentGroups;
protected GlobalSettingRepository $globalSettings;
public function __construct() {
$this->tenant = currentTenant();
@@ -45,6 +49,8 @@ abstract class CommonController {
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
$this->adminParentGroups = new AdminParentGroupRepository();
$this->globalSettings = app(GlobalSettingRepository::class);
}
protected function checkAuth() {
@@ -0,0 +1,102 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Übergeordnete Gruppen (Parent Groups, im UI z. B. „Bund"): jeder Mandant (Local Group) gehört zu genau einer.
* Eine Veranstaltung fragt bei der Anmeldung entweder nach der Local Group oder nach der Parent Group.
*
* Die Vorbelegung steht hier und nicht im Seeder: der Fremdschlüssel am Tenant ist Pflicht, bestehende
* Installationen brauchen die Fallback-Gruppe also schon beim Migrieren.
*
* Dazu die globalen Einstellungen mit den Bezeichnungen der beiden Ebenen -- aus demselben Grund vorbelegt.
*/
public function up(): void
{
Schema::create('parent_groups', function (Blueprint $table) {
$table->id();
$table->string('name');
// Die Gruppe für Mandanten ohne Zuordnung; sie ist umbenennbar, aber nie löschbar.
$table->boolean('is_fallback')->default(false);
$table->timestamps();
});
$now = now();
$fallbackId = DB::table('parent_groups')->insertGetId([
'name' => 'Ohne Zuordnung',
'is_fallback' => true,
'created_at' => $now,
'updated_at' => $now,
]);
foreach (['BdP', 'DPV', 'DPSG', 'VCP'] as $name) {
DB::table('parent_groups')->insert([
'name' => $name,
'is_fallback' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable()->constrained('parent_groups')->restrictOnDelete();
});
DB::table('tenants')->update(['parent_group_id' => $fallbackId]);
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable(false)->change();
});
Schema::table('events', function (Blueprint $table) {
$table->string('participant_group_type')->default('local_group');
});
Schema::table('event_participants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable()->constrained('parent_groups')->restrictOnDelete();
});
Schema::create('global_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->text('value');
$table->timestamps();
});
foreach ([
'local_group_label' => 'Stamm',
'local_group_label_plural' => 'Stämme',
'parent_group_label' => 'Bund',
'parent_group_label_plural' => 'Bünde',
] as $key => $value) {
DB::table('global_settings')->insert([
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
Schema::dropIfExists('global_settings');
Schema::table('event_participants', function (Blueprint $table) {
$table->dropConstrainedForeignId('parent_group_id');
});
Schema::table('events', function (Blueprint $table) {
$table->dropColumn('participant_group_type');
});
Schema::table('tenants', function (Blueprint $table) {
$table->dropConstrainedForeignId('parent_group_id');
});
Schema::dropIfExists('parent_groups');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
// Ob bei Anmeldungen das erweiterte Führungszeugnis geprüft wird. Ist die Prüfung aus, erhalten neue
// Anmeldungen immer „Nicht erforderlich". Default true, damit bestehende Veranstaltungen sich
// unverändert verhalten.
$table->boolean('coc_check_required')->default(true);
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn('coc_check_required');
});
}
};
+34
View File
@@ -13,6 +13,13 @@
set -euo pipefail
MAREIKE_BASE="${MAREIKE_BASE:-/opt/mareike}"
# Server-spezifische Einstellungen, z. B. MAREIKE_DB_SOCKET_DIR=/var/lib/mysql
if [[ -f "$MAREIKE_BASE/shared/deploy.conf" ]]; then
# shellcheck source=/dev/null
source "$MAREIKE_BASE/shared/deploy.conf"
fi
MAREIKE_USER="${MAREIKE_USER:-mareike-docker}"
SRC="$MAREIKE_BASE/src"
@@ -48,6 +55,28 @@ if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
exit 1
fi
# Der Container läuft als $MAREIKE_USER und muss die eingebundene .env lesen können.
# Ist sie unlesbar, ignoriert Laravel sie still und fällt z. B. auf SQLite zurück.
chown "root:$MAREIKE_GID" "$MAREIKE_ENV_FILE"
chmod 0640 "$MAREIKE_ENV_FILE"
# Der Container erreicht die KeyHelp-MariaDB nur über den Socket im eingebundenen Verzeichnis /run/mysqld.
DB_SOCKET="$( (grep -E '^DB_SOCKET=' "$MAREIKE_ENV_FILE" || true) | tail -n 1 | cut -d= -f2- | tr -d "\"' \r")"
if [[ -z "$DB_SOCKET" ]]; then
echo "DB_SOCKET fehlt in $MAREIKE_ENV_FILE (erwartet: DB_SOCKET=/run/mysqld/mysqld.sock)." >&2
exit 1
fi
if [[ "$(dirname "$DB_SOCKET")" != "/run/mysqld" ]]; then
echo "DB_SOCKET muss in /run/mysqld/ liegen (Mount-Punkt im Container), ist aber: $DB_SOCKET" >&2
exit 1
fi
if [[ ! -S "$MAREIKE_DB_SOCKET_DIR/$(basename "$DB_SOCKET")" ]]; then
echo "Kein MariaDB-Socket unter $MAREIKE_DB_SOCKET_DIR/$(basename "$DB_SOCKET")." >&2
echo "Pfad prüfen mit: mysql -NBe 'SELECT @@socket;'" >&2
echo "Liegt er woanders, MAREIKE_DB_SOCKET_DIR in $MAREIKE_BASE/shared/deploy.conf setzen." >&2
exit 1
fi
echo "==> Quellcode auf '$REF' bringen"
git -C "$SRC" fetch --tags --prune --force origin
if git -C "$SRC" show-ref --verify --quiet "refs/remotes/origin/$REF"; then
@@ -70,6 +99,11 @@ done
echo "==> Images bauen"
compose build --pull
if ! compose run --rm --no-deps app php -r 'exit(is_readable(".env") ? 0 : 1);'; then
echo ".env ist im Container nicht lesbar – Besitzer/Rechte von $MAREIKE_ENV_FILE prüfen." >&2
exit 1
fi
echo "==> Datenbank migrieren"
compose run --rm --no-deps app php artisan migrate --force
+2 -2
View File
@@ -7,8 +7,8 @@ APP_ENV=production
APP_DEBUG=false
# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
APP_KEY=
# Domain des Dach-Tenants "lv"
APP_URL=https://<lv-subdomain>.meine-anmeldung.digital
# Die Hauptdomain ist der Dach-Tenant "lv"; Gruppen laufen als <gruppe>.meine-anmeldung.digital
APP_URL=https://meine-anmeldung.digital
# Kein ASSET_URL: Assets kommen von der jeweiligen Tenant-Domain selbst.
APP_LOCALE=de
+4
View File
@@ -17,6 +17,10 @@ server {
index index.php;
server_tokens off;
# nginx sieht hinter dem Proxy nur http. Eigene Redirects (z. B. /tinymce → /tinymce/) daher relativ halten,
# sonst landet der Browser auf http://.
absolute_redirect off;
client_max_body_size 70m;
location / {
+75 -22
View File
@@ -51,7 +51,8 @@ Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, ein
/opt/mareike/
├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
├── shared/.env Konfiguration mit Zugangsdaten (nur root lesbar)
├── shared/.env Konfiguration mit Zugangsdaten (root, Gruppe mareike-docker lesbar, 0640)
├── shared/deploy.conf optional: Server-Einstellungen für deploy.sh (z. B. MAREIKE_DB_SOCKET_DIR)
└── storage/ Laravel-storage/ (Logs, Sessions, erzeugte PDFs/ZIPs) – persistent
```
@@ -152,8 +153,18 @@ install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/dep
mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
```
Liegt der Socket woanders, in `/opt/mareike/bin/deploy.sh` die Variable `MAREIKE_DB_SOCKET_DIR` auf das Verzeichnis
setzen und `DB_SOCKET` in der `.env` anpassen.
Liegt der Socket woanders, z. B. unter `/var/lib/mysql/mysql.sock`, das **Verzeichnis** in
`/opt/mareike/shared/deploy.conf` eintragen:
```bash
echo 'MAREIKE_DB_SOCKET_DIR=/var/lib/mysql' > /opt/mareike/shared/deploy.conf
```
In der `.env` bleibt das Verzeichnis `/run/mysqld`, denn so heißt der Mount-Punkt im Container. Nur der Dateiname
wird übernommen, im Beispiel also `DB_SOCKET=/run/mysqld/mysql.sock`.
`deploy.sh` prüft vor jedem Build, ob `DB_SOCKET` gesetzt ist und der Socket auf dem Host existiert. Wenn nicht,
bricht es mit einem Hinweis ab.
Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-Backup** enthalten.
@@ -162,7 +173,7 @@ Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-
## 5. `.env` anlegen
```bash
install -m 0600 /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
install -m 0640 -o root -g mareike-docker /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
echo "base64:$(openssl rand -base64 32)" # Ergebnis als APP_KEY eintragen
nano /opt/mareike/shared/.env
```
@@ -225,7 +236,7 @@ angelegt wird.
App\Models\Tenant::create([
'slug' => 'lv',
'name' => '<Name des Verbands>',
'url' => '<lv-subdomain>.meine-anmeldung.digital',
'url' => 'meine-anmeldung.digital',
'email' => '<kontakt@…>',
'email_finance' => '<finanzen@…>',
'account_name' => '<Kontoinhaber>',
@@ -249,7 +260,7 @@ global und werden für jeden aktiven Tenant ausgeführt.
### 6.4 Erste Administratorin / ersten Administrator
1. Auf `https://<lv-subdomain>.meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
1. Auf `https://meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
erledigt und der Mailversand funktioniert.
2. Das Konto zur Administration hochstufen:
@@ -261,20 +272,36 @@ global und werden für jeden aktiven Tenant ausgeführt.
## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
Diesen Ablauf für den Dach-Tenant `lv` und später für jede weitere Gruppe wiederholen.
Domain-Schema:
### 7.1 Einmalig: Proxy-Module aktivieren (Apache)
- Dach-Tenant `lv`: die **Hauptdomain** `meine-anmeldung.digital`. Sie existiert in KeyHelp bereits samt
Let's-Encrypt-Zertifikat.
- Gruppen: `<gruppe>.meine-anmeldung.digital`, jede als eigene Domain in KeyHelp mit eigenem Zertifikat.
Den folgenden Ablauf für die Hauptdomain und später für jede Gruppe wiederholen.
> **Wichtig:** Nicht die KeyHelp-Funktion **„Weiterleitung“** benutzen. Sie schickt den *Browser* auf
> `http://127.0.0.1:8080`, und das kann nicht funktionieren. Gebraucht wird ein **Proxy**: KeyHelp holt die Seite
> intern vom Container ab und liefert sie selbst per HTTPS mit seinem Zertifikat aus. Das geschieht ausschließlich über
> die Webserver-Direktiven unten.
### 7.1 Einmalig: Module aktivieren (Apache)
```bash
a2enmod proxy proxy_http headers
a2enmod proxy proxy_http headers rewrite
systemctl reload apache2
```
### 7.2 Domain anlegen
### 7.2 Domain einrichten
1. KeyHelp → **Domains** → neue Domain für das mareike-Konto, z. B. `wilde-moehre.meine-anmeldung.digital`.
Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht selbst verwaltet.
2. **SSL/TLS:** Let's Encrypt aktivieren und **HTTPS erzwingen** einschalten.
1. **Hauptdomain:** In KeyHelp die vorhandene Domain `meine-anmeldung.digital` öffnen. Eine eventuell eingerichtete
*Weiterleitung* entfernen.
**Gruppe:** KeyHelp → **Domains** → neue Domain für das mareike-Konto anlegen, z. B.
`wilde-moehre.meine-anmeldung.digital`. Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht
selbst verwaltet.
2. **SSL/TLS:** Let's Encrypt aktiv und **HTTPS erzwingen** eingeschaltet. Bei der Hauptdomain muss
`www.meine-anmeldung.digital` im Zertifikat enthalten sein, sonst gibt es beim www-Redirect eine
Zertifikatswarnung.
3. PHP wird für diese Domain nicht gebraucht.
4. In den Domain-Einstellungen im Feld für **individuelle Webserver-Direktiven** (nur als Admin sichtbar, die
Bezeichnung variiert je nach KeyHelp-Version) Folgendes eintragen:
@@ -282,17 +309,34 @@ systemctl reload apache2
**Apache** (KeyHelp-Standard):
```apache
RewriteEngine On
# Let's Encrypt von KeyHelp nicht anfassen
RewriteRule ^/?\.well-known/acme-challenge/ - [L]
# www → ohne www
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^/?(.*)$ https://%1/$1 [R=301,L,NE]
# alles ohne TLS auf https umleiten
RewriteCond %{HTTPS} !=on
RewriteRule ^/?(.*)$ https://%{HTTP_HOST}/$1 [R=301,L,NE]
# unter https an den Container durchreichen
RewriteRule ^/?(.*)$ http://127.0.0.1:8080/$1 [P,L,NE]
ProxyPreserveHost On
ProxyRequests Off
RequestHeader set X-Forwarded-Proto "https"
ProxyPass /.well-known/acme-challenge/ !
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
RequestHeader set X-Forwarded-Proto "https"
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
```
Die Regeln funktionieren unabhängig davon, ob KeyHelp die Direktiven in den Port-80- oder den Port-443-vHost
schreibt. Ohne TLS wird immer auf `https://` umgeleitet, nur unter HTTPS wird an den Container weitergereicht. Das
`http://127.0.0.1:8080` ist dabei die **interne** Verbindung zum Container, der Browser sieht sie nie.
**nginx** (falls KeyHelp auf nginx läuft):
```nginx
if ($scheme != "https") {
return 301 https://$host$request_uri;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
@@ -301,9 +345,6 @@ systemctl reload apache2
client_max_body_size 70m;
}
```
Die Ausnahme für `/.well-known/acme-challenge/` sorgt dafür, dass KeyHelp die Zertifikate weiter selbst verlängern
kann.
5. Speichern und die Domain aufrufen. Solange der Tenant noch nicht existiert, zeigt mareike **404 „Tenant not
found“**. Das ist ein gutes Zeichen, denn der Proxy funktioniert.
@@ -324,7 +365,7 @@ Tenants:
```bash
cat > /etc/cron.d/mareike <<'EOF'
* * * * * root curl -fsS -o /dev/null -H "Host: <lv-subdomain>.meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
* * * * * root curl -fsS -o /dev/null -H "Host: meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
EOF
```
@@ -378,14 +419,26 @@ Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
| Symptom | Ursache / Lösung |
|---|---|
| `Database file at path […database.sqlite] does not exist` | Die `.env` wird im Container nicht gelesen, Laravel fällt dann auf SQLite zurück. Rechte prüfen (0640, Gruppe `mareike-docker`) und ob `MAREIKE_ENV_FILE` auf die richtige Datei zeigt. |
| Browser landet auf `http://` | In KeyHelp ist eine *Weiterleitung* statt der Proxy-Direktiven eingerichtet, oder es sind noch die alten `ProxyPass`-Direktiven ohne HTTPS-Umleitung aktiv (Abschnitt 7.2). Woher die http-Umleitung kommt, zeigen die drei `curl`-Befehle darunter. |
| 404 „Tenant not found“ | Der Host passt nicht zu `tenants.url` (Tippfehler, `https://` oder `/` im Eintrag), der Tenant ist nicht `has_active_instance`, oder der Proxy reicht den Host nicht durch (`ProxyPreserveHost On` fehlt). |
| 502/503 vom KeyHelp-Webserver | Container laufen nicht (`deploy.sh compose ps`) oder die Firewall wurde neu geladen, dann hilft `systemctl restart docker`. |
| Seite lädt ohne CSS, Mixed Content | `ASSET_URL` ist gesetzt oder die nginx-Config im Container wurde nicht übernommen (`fastcgi_param HTTPS on`). |
| `SQLSTATE[HY000] [2002]` | Socket nicht erreichbar. `SELECT @@socket` prüfen, `MAREIKE_DB_SOCKET_DIR` bzw. `DB_SOCKET` anpassen, nach einem MariaDB-Neustart den Stack mit `deploy.sh compose restart app` neu starten. |
| `SQLSTATE[HY000] [2002] No such file or directory` | Im Container liegt kein Socket unter `DB_SOCKET`. Entweder fehlt `DB_SOCKET` in der `.env`, oder der Socket liegt auf dem Host nicht in `/run/mysqld`. Pfad mit `mysql -NBe "SELECT @@socket;"` prüfen und ggf. `MAREIKE_DB_SOCKET_DIR` in `shared/deploy.conf` setzen (Abschnitt 4). Nach einem MariaDB-Neustart hilft oft `deploy.sh compose restart app`. |
| `Permission denied` in storage/ | Besitzer von `/opt/mareike/storage` muss `mareike-docker` sein, der nächste `deploy.sh`-Lauf korrigiert das. |
| Upload bricht ab | Grenzen prüfen: `MAX_INVOICE_FILE_SIZE` in der `.env`, 64 MB in `php.ini`, 70 MB in `nginx.conf`. |
| Mails kommen nicht an | `MAIL_*` prüfen, Log in `storage/logs/`. Absenderadresse muss zum Postfach passen (SPF/DKIM der Domain in KeyHelp). |
Woher kommt eine Umleitung auf `http://`?
```bash
curl -sI http://meine-anmeldung.digital/ | grep -iE '^(HTTP|Location)' # erwartet: 301 → https://meine-anmeldung.digital/
curl -sI https://meine-anmeldung.digital/ | grep -iE '^(HTTP|Location)' # erwartet: 302 → https://meine-anmeldung.digital/login
curl -sI -H 'Host: meine-anmeldung.digital' http://127.0.0.1:8080/ | grep -i '^Location' # Container direkt: https://…/login
```
Zeigt der dritte Befehl `https://`, der zweite aber `http://`, liegt es an den KeyHelp-Einstellungen, nicht an mareike.
Logs:
```bash
+8 -1
View File
@@ -15,6 +15,7 @@ const globalProps = reactive({
message: '',
currentEvent: null,
version: '',
groupLabels: {localGroupPlural: 'Stämme', parentGroupPlural: 'Bünde'},
});
const sidebarOpen = ref(false);
@@ -107,7 +108,10 @@ const props = defineProps({
</ul>
<ul class="nav-links">
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/tenants" @click="closeSidebar">Stämme</a>
<a href="/admin/tenants" @click="closeSidebar">{{ globalProps.groupLabels.localGroupPlural }}</a>
</li>
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/parent-groups" @click="closeSidebar">{{ globalProps.groupLabels.parentGroupPlural }}</a>
</li>
<li>
<a href="/admin/users" @click="closeSidebar">Benutzer*innen</a>
@@ -117,6 +121,9 @@ const props = defineProps({
<li>
<a href="/admin/document-templates" @click="closeSidebar">Rechnungsvorlage</a>
</li>
<li>
<a href="/admin/settings" @click="closeSidebar">Einstellungen</a>
</li>
</ul>
</nav>
</div>
@@ -1,10 +1,12 @@
@php
$rows = $rows ?? [];
$localGroupLabel = $localGroupLabel ?? 'Stamm';
$participantGroupLabel = $participantGroupLabel ?? $localGroupLabel;
@endphp
"Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat), "Wohnort (Privat)", "Bundesland (Privat)", "Alter", "Postleitzahl (Stamm)", "Wohnort (Stamm)", "Bundesland (Stamm)","Stamm", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag"
"Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat)", "Wohnort (Privat)", "Bundesland (Privat)", "Alter", "Postleitzahl ({{ $localGroupLabel }})", "Wohnort ({{ $localGroupLabel }})", "Bundesland ({{ $localGroupLabel }})","{{ $participantGroupLabel }}", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag"
@foreach($rows as $index => $row)
"{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['city'] }}","{{ $row['state'] }}","{{ $row['age'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['localGroupCity'] }}","{{ $row['localGroupState'] }}","{{ $row['localgroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}"
"{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['city'] }}","{{ $row['state'] }}","{{ $row['age'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['localGroupCity'] }}","{{ $row['localGroupState'] }}","{{ $row['participantGroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}"
@endforeach
+2 -2
View File
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Teilnahmegruppe</th>
<th style="width: 3.5cm;">Anreise<br />Abreise</th>
<th style="width: 3.5cm;">Beitrag</th>
@@ -61,7 +61,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['participationType'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br />{{$row['departure']}}<br />{{$row['presenceDays']['real']}} Tage</td>
<td>{{ $row['amountPaid']['readable'] ?? '' }} / {{ $row['amountExpected']['readable'] ?? '' }}</td>
+2 -2
View File
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 3cm;">Softdrinks 1</th>
<th style="width: 3cm;">Softdrinks 2</th>
<th style="width: 3cm;">Softdrinks 3</th>
@@ -63,7 +63,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td></td>
<td></td>
<td></td>
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Bade-Erlaubnis</th>
<th style="width: 3.5cm;">Allergien /<br />Unvertr.</th>
<th style="width: 3.5cm;">Medikamente</th>
@@ -63,7 +63,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['swimmingPermission'] ?? '' }}</td>
<td>{{ $row['allergies'] ?? '' }}<br />{{$row['intolerances']}}</td>
<td>{{ $row['medications'] ?? '' }}</td>
@@ -186,8 +186,8 @@
<div class="footnote">
Berücksichtigt sind nur tatsächlich vereinnahmte Beiträge und erfasste Belege. Nicht enthalten sind
offene Beiträge, Budget- und Schätzwerte, erstattete Beiträge sowie Auslagen, auf deren Auszahlung
verzichtet wurde.
offene Beiträge, Budget- und Schätzwerte sowie erstattete Beiträge. Gespendete Auslagen sind bei den
Ausgaben enthalten und zugleich als Spenden unter den Förderungen ausgewiesen.
@if($event->tax_liable)
<br />Alle Beträge sind Bruttobeträge inklusive {{ $event->vat_rate }} % Umsatzsteuer.
@endif
@@ -203,33 +203,27 @@
<h1>Anlage: Belege</h1>
<div class="subline">{{ $event->name }} &middot; Stand: {{ $createdAt }}</div>
@foreach($documentedGroups as $group)
<h3>{{ $group['name'] }}</h3>
<table class="receipts">
<thead>
<tr>
<th style="width: 22mm;">Beleg-Nr.</th>
<th style="width: 18mm;">Datum</th>
<th>Zweck</th>
<th class="value">Betrag</th>
@include('pdfs.partials.receipt-groups', ['groups' => $documentedGroups])
@endif
@php
$donatedGroups = array_filter($donations['groups'], fn ($group) => count($group['rows']) > 0);
@endphp
@if(count($donatedGroups) > 0)
<div class="page-break"></div>
<h1>Anlage: Spenden</h1>
<div class="subline">{{ $event->name }} &middot; Stand: {{ $createdAt }}</div>
@include('pdfs.partials.receipt-groups', ['groups' => $donatedGroups])
<table class="statement">
<tr class="sum">
<td>Summe Spenden</td>
<td class="value">{{ $money($donations['total']) }} &euro;</td>
</tr>
</thead>
<tbody>
@foreach($group['rows'] as $row)
<tr>
<td>{{ $row['number'] }}</td>
<td>{{ $row['date'] }}</td>
<td>{{ $row['purpose'] }}</td>
<td class="value">{{ $money($row['amount']) }} &euro;</td>
</tr>
@endforeach
<tr class="group-sum">
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
<td class="value">{{ $money($group['sum']) }} &euro;</td>
</tr>
</tbody>
</table>
@endforeach
@endif
</body>
+2 -2
View File
@@ -99,7 +99,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Teilnahmegruppe</th>
<th style="width: 3.5cm;">Anreise<br/>Abreise</th>
<th style="width: 3.5cm;">Unverträglichkeiten</th>
@@ -110,7 +110,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br/>{{ $row['firstname'] }}<br/> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['participationType'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br/>{{$row['departure']}}<br/>{{$row['presenceDays']['real']}} Tage</td>
<td>{{ $row['intolerances'] }}</td>
@@ -0,0 +1,28 @@
{{-- Belege je Ausgabentyp mit Zwischensumme; nur Gruppen mit Belegen übergeben. --}}
@foreach($groups as $group)
<h3>{{ $group['name'] }}</h3>
<table class="receipts">
<thead>
<tr>
<th style="width: 22mm;">Beleg-Nr.</th>
<th style="width: 18mm;">Datum</th>
<th>Zweck</th>
<th class="value">Betrag</th>
</tr>
</thead>
<tbody>
@foreach($group['rows'] as $row)
<tr>
<td>{{ $row['number'] }}</td>
<td>{{ $row['date'] }}</td>
<td>{{ $row['purpose'] }}</td>
<td class="value">{{ $money($row['amount']) }} &euro;</td>
</tr>
@endforeach
<tr class="group-sum">
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
<td class="value">{{ $money($group['sum']) }} &euro;</td>
</tr>
</tbody>
</table>
@endforeach
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 3.0cm;">Anreise<br />Abreise</th>
<th style="width: 3.0cm;">Fotos<br />Printmedien</th>
<th style="width: 3.0cm;">Fotos<br />Webseite</th>
@@ -64,7 +64,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br />{{$row['departure']}}<br />{{$row['presenceDays']['real']}} Tage</td>
<td style="text-align:center; vertical-align: middle; font-weight: bold;">{{ $row['foto_print'] ? 'Ja' : 'Nein' }}</td>
<td style="text-align:center; vertical-align: middle; font-weight: bold;">{{ $row['foto_webseite'] ? 'Ja' : 'Nein' }}</td>
@@ -202,11 +202,95 @@ class EventIncomeSurplusStatementTest extends TestCase
// Kein Geldfluss, also keine Ausgabe:
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED);
// Eine Spende ist Ausgabe ihres Typs (und zugleich Einnahme unter den Förderungen):
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->assertEqualsWithDelta(150.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(927.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(30.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(180.0, $this->statement()->expenses['total']->getAmount(), 0.001);
$this->assertEqualsWithDelta(957.0, $this->statement()->expenses['total']->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Spenden
|--------------------------------------------------------------------------
*/
public function test_without_donations_the_donation_line_is_zero(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertEqualsWithDelta(0.0, $this->entry('Spenden')->getAmount(), 0.001);
$this->assertEqualsWithDelta(0.0, $this->statement()->donations['total']->getAmount(), 0.001);
}
public function test_a_donation_is_an_expense_of_its_type_and_funding_income(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$resultWithoutDonation = $this->statement()->result->getAmount();
$donated = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 40.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
$statement = $this->statement();
$program = $this->group('Programmkosten');
$this->assertEqualsWithDelta(140.0, $program['sum']->getAmount(), 0.001);
$this->assertContains($donated->invoice_number, array_column($program['rows'], 'number'));
// Gegenbuchung unter den Förderungen (ohne Anmeldung keine Fördermittel, also nur die Spende).
$this->assertSame('Förderungen', $statement->income['categories'][1]['name']);
$this->assertEqualsWithDelta(40.0, $this->entry('Spenden')->getAmount(), 0.001);
$this->assertEqualsWithDelta(40.0, $statement->income['categories'][1]['total']->getAmount(), 0.001);
// Ausgabe und Einnahme heben sich auf.
$this->assertEqualsWithDelta($resultWithoutDonation, $statement->result->getAmount(), 0.001);
}
public function test_donations_are_grouped_like_expenses(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$programDonation = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 25.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 15.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 60.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
$donations = $this->statement()->donations;
// Dieselbe Gliederung wie die Ausgaben, auch Typen ohne Spende stehen darin.
$this->assertSame(
array_column($this->statement()->expenses['groups'], 'name'),
array_column($donations['groups'], 'name')
);
$this->assertEqualsWithDelta(40.0, $this->donationGroup('Programmkosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(60.0, $this->donationGroup('Verpflegungskosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(100.0, $donations['total']->getAmount(), 0.001);
$this->assertEqualsWithDelta(100.0, $this->entry('Spenden')->getAmount(), 0.001);
// Derselbe Beleg steht als Ausgabe und als Spende.
$this->assertContains($programDonation->invoice_number, array_column($this->group('Programmkosten')['rows'], 'number'));
$this->assertContains($programDonation->invoice_number, array_column($this->donationGroup('Programmkosten')['rows'], 'number'));
// Nicht gespendete Belege stehen nicht in der Spenden-Anlage.
$this->assertCount(2, $this->donationGroup('Programmkosten')['rows']);
}
public function test_donations_list_only_booked_donations(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 20.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
$donations = $this->statement()->donations;
$this->assertEqualsWithDelta(20.0, $donations['total']->getAmount(), 0.001);
$this->assertCount(1, $this->donationGroup('Programmkosten')['rows']);
$this->assertNotContains('Beitragserstattung', array_column($donations['groups'], 'name'));
}
public function test_a_refund_is_no_expense(): void
@@ -311,12 +395,34 @@ class EventIncomeSurplusStatementTest extends TestCase
public function test_the_comment_is_no_longer_part_of_the_purpose(): void
{
// Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem
// Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben
// hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren.
// Beleg-PDF und hat im Zweck nichts zu suchen.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']);
$this->assertStringNotContainsString('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
}
public function test_an_empty_purpose_falls_back_to_the_refund_of_the_contact(): void
{
// Ältere Abrechnungen ohne jede Zweckangabe: ersatzweise, wem erstattet wurde -- als Ausgabe wie als Spende.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 30.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(
InvoiceType::INVOICE_TYPE_PROGRAM,
50.0,
InvoiceStatus::INVOICE_STATUS_NEW,
purpose: 'Material für den Bastelnachmittag'
);
$this->assertSame(
['Auslagenerstattung Mika Muster', 'Auslagenerstattung Mika Muster', 'Material für den Bastelnachmittag'],
array_column($this->group('Programmkosten')['rows'], 'purpose')
);
$this->assertSame(
['Auslagenerstattung Mika Muster'],
array_column($this->donationGroup('Programmkosten')['rows'], 'purpose')
);
}
/*
@@ -388,6 +494,20 @@ class EventIncomeSurplusStatementTest extends TestCase
);
}
public function test_a_donation_still_does_not_count_in_the_expense_overview(): void
{
// Die Bildschirm-Übersicht kennt keine Spenden-Einnahme -- dort bleibt die Spende außen vor.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$amount = new CostUnitRepository()->sumupByInvoiceType(
$this->costUnit->fresh(),
InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first()
);
$this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Helfer
@@ -435,6 +555,18 @@ class EventIncomeSurplusStatementTest extends TestCase
$this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.');
}
/** @return array{name: string, sum: Amount, rows: array<int, array<string, mixed>>} */
private function donationGroup(string $name): array
{
foreach ($this->statement()->donations['groups'] as $group) {
if ($group['name'] === $name) {
return $group;
}
}
$this->fail('Spenden-Gruppe "' . $name . '" fehlt.');
}
private function makeEvent(): Event
{
$fee = EventParticipationFee::create([
+2 -1
View File
@@ -40,6 +40,7 @@ class InvoiceNumberingTest extends TestCase
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
@@ -167,7 +168,7 @@ class InvoiceNumberingTest extends TestCase
$event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
$this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
strtolower($firstname) . '@example.com', '123', null, null, null, null, null, null, null,
'vegan', null, null, false, false, false, false, false,
'', null, null, false, false, false, false, false,
new DateTime('2026-07-16'), new DateTime('2026-07-16'), 0, 0, null,
new Amount(50.0, 'Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
))->execute();
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace Tests\Feature;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupAction;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupRequest;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupAction;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupRequest;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupAction;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupRequest;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsAction;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsRequest;
use App\Domains\Admin\Repositories\AdminParentGroupRepository;
use App\Domains\Event\Actions\CreateEvent\CreateEventCommand;
use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\Repositories\GlobalSettingRepository;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ParentGroupTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private AdminParentGroupRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->tenant = $this->createTenant('tv', 'Test');
app()->instance('tenant', $this->tenant);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
$this->repository = new AdminParentGroupRepository();
}
private function createTenant(string $slug, string $name, ?ParentGroup $parentGroup = null): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
'email_finance' => $slug . '-f@example.com', 'url' => $slug . '.local',
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => true, 'tax_liable' => false, 'vat_rate' => 0, 'vat_pricing_mode' => 'inclusive',
'parent_group_id' => $parentGroup?->id,
]);
}
private function parentGroup(string $name): ParentGroup
{
return ParentGroup::where('name', $name)->firstOrFail();
}
private function createEvent(ParticipantGroupType $type = ParticipantGroupType::LocalGroup): Event
{
$response = new CreateEventCommand(new CreateEventRequest(
name: 'Sommerlager',
location: 'Leipzig',
postalCode: '04103',
email: 'e@example.com',
begin: new DateTime('2026-09-01'),
end: new DateTime('2026-09-05'),
earlyBirdEnd: new DateTime('2026-08-20'),
registrationFinalEnd: new DateTime('2026-08-25'),
earlyBirdEndAmountIncrease: 0,
participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(),
accountOwner: 'Owner',
accountIban: 'DE00',
payPerDay: false,
participantGroupType: $type,
))->execute();
$this->assertTrue($response->success);
return $response->event->fresh();
}
private function updateGroupType(Event $event, ParticipantGroupType $type): Event
{
new UpdateEventCommand(new UpdateEventRequest(
$event, $event->name, $event->location, $event->postal_code, $event->email,
new DateTime('2026-08-20'), new DateTime('2026-08-25'), 16, true, true,
Amount::fromString('0,00'), Amount::fromString('0,00'),
$event->localGroups->pluck('id')->all(), [],
participantGroupType: $type,
))->execute();
return $event->fresh();
}
// --- Vorbelegung und Tenant --------------------------------------------------------------------------
public function test_vorbelegung_ist_vorhanden(): void
{
$this->assertEqualsCanonicalizing(
['Ohne Zuordnung', 'BdP', 'DPV', 'DPSG', 'VCP'],
ParentGroup::pluck('name')->all(),
);
$this->assertSame('Ohne Zuordnung', ParentGroup::fallback()->name);
}
public function test_neuer_tenant_ohne_angabe_landet_bei_ohne_zuordnung(): void
{
$this->assertSame(ParentGroup::fallback()->id, $this->tenant->fresh()->parent_group_id);
}
// --- Verwaltung ----------------------------------------------------------------------------------------
public function test_anlegen_und_umbenennen(): void
{
$created = new CreateParentGroupAction(new CreateParentGroupRequest(' Pfadfinderbund '))->execute();
$this->assertTrue($created->success);
$this->assertSame('Pfadfinderbund', $created->parentGroup->name);
$renamed = new RenameParentGroupAction(new RenameParentGroupRequest($created->parentGroup, 'Neuer Name'))->execute();
$this->assertTrue($renamed->success);
$this->assertSame('Neuer Name', $created->parentGroup->fresh()->name);
}
public function test_leerer_name_wird_abgelehnt(): void
{
$this->assertFalse(new CreateParentGroupAction(new CreateParentGroupRequest(' '))->execute()->success);
$this->assertFalse(
new RenameParentGroupAction(new RenameParentGroupRequest($this->parentGroup('BdP'), ''))->execute()->success
);
$this->assertSame('BdP', $this->parentGroup('BdP')->name);
}
public function test_unbenutzte_gruppe_ist_loeschbar(): void
{
$dpv = $this->parentGroup('DPV');
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($dpv, $this->repository->isInUse($dpv)))->execute();
$this->assertTrue($response->success);
$this->assertNull(ParentGroup::find($dpv->id));
}
public function test_zugeordnete_gruppe_ist_nicht_loeschbar(): void
{
$bdp = $this->parentGroup('BdP');
$this->createTenant('stamm-a', 'Stamm A', $bdp);
$this->assertTrue($this->repository->isInUse($bdp));
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($bdp, $this->repository->isInUse($bdp)))->execute();
$this->assertFalse($response->success);
$this->assertNotNull(ParentGroup::find($bdp->id));
}
public function test_fallback_ist_nie_loeschbar(): void
{
$fallback = ParentGroup::fallback();
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($fallback, false))->execute();
$this->assertFalse($response->success);
$this->assertNotNull(ParentGroup::find($fallback->id));
}
// --- Bezeichnungen -------------------------------------------------------------------------------------
public function test_bezeichnungen_haben_vorgaben(): void
{
$this->assertSame(
['localGroup' => 'Stamm', 'localGroupPlural' => 'Stämme', 'parentGroup' => 'Bund', 'parentGroupPlural' => 'Bünde'],
ParticipantGroupType::labels(),
);
}
public function test_geaenderte_bezeichnung_wirkt_auf_enum_und_event(): void
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
app(GlobalSettingRepository::class), 'Ortsgruppe', 'Ortsgruppen', 'Verband', 'Verbände',
))->execute();
$this->assertTrue($response->success);
$this->assertSame('Verband', ParticipantGroupType::ParentGroup->label());
$this->assertSame('Ortsgruppen', ParticipantGroupType::LocalGroup->pluralLabel());
}
public function test_leere_bezeichnung_wird_abgelehnt(): void
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
app(GlobalSettingRepository::class), 'Ortsgruppe', '', 'Verband', 'Verbände',
))->execute();
$this->assertFalse($response->success);
$this->assertSame('Stamm', ParticipantGroupType::LocalGroup->label());
}
public function test_fehlende_einstellung_faellt_auf_vorgabe_zurueck(): void
{
DB::table('global_settings')->delete();
$this->assertSame('Bund', app(GlobalSettingRepository::class)->get('parent_group_label', 'Bund'));
$this->assertSame('Stämme', ParticipantGroupType::LocalGroup->pluralLabel());
}
// --- Event -------------------------------------------------------------------------------------------
public function test_anlage_speichert_abfrage_nach_bund(): void
{
$this->assertSame('parent_group', $this->createEvent(ParticipantGroupType::ParentGroup)->participant_group_type);
$this->assertSame('local_group', $this->createEvent()->participant_group_type);
}
public function test_optionen_sind_die_buende_der_teilnehmenden_staemme(): void
{
$event = $this->createEvent(ParticipantGroupType::ParentGroup);
$event->localGroups()->sync([
$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('VCP'))->id,
$this->createTenant('stamm-b', 'Stamm B', $this->parentGroup('BdP'))->id,
$this->createTenant('stamm-c', 'Stamm C', $this->parentGroup('BdP'))->id,
]);
// Jeder Bund nur einmal, alphabetisch.
$this->assertSame(['BdP', 'VCP'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_optionen_listen_ohne_zuordnung_zuletzt(): void
{
$event = $this->createEvent(ParticipantGroupType::ParentGroup);
$event->localGroups()->sync([
$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('VCP'))->id,
$this->createTenant('stamm-b', 'Stamm B', ParentGroup::fallback())->id,
$this->createTenant('stamm-c', 'Stamm C', $this->parentGroup('BdP'))->id,
]);
$this->assertSame(['BdP', 'VCP', 'Ohne Zuordnung'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_verwaltung_listet_alphabetisch_mit_ohne_zuordnung_zuletzt(): void
{
new CreateParentGroupAction(new CreateParentGroupRequest('Pfadfinderbund'))->execute();
$this->assertSame(
['BdP', 'DPSG', 'DPV', 'Pfadfinderbund', 'VCP', 'Ohne Zuordnung'],
$this->repository->all()->pluck('name')->all(),
);
}
public function test_optionen_sind_bei_stamm_abfrage_die_staemme(): void
{
$event = $this->createEvent();
$event->localGroups()->sync([$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('BdP'))->id]);
$this->assertSame(['Stamm A'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_umstellen_ist_ohne_anmeldungen_moeglich(): void
{
$event = $this->updateGroupType($this->createEvent(), ParticipantGroupType::ParentGroup);
$this->assertSame('parent_group', $event->participant_group_type);
}
public function test_umstellen_ist_nach_der_ersten_anmeldung_gesperrt(): void
{
$event = $this->createEvent();
// Eine Anmeldung genügt -- direkt geschrieben, der Anmeldeprozess ist hier nicht Gegenstand.
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
DB::table('event_participants')->insert([
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'tenant' => $this->tenant->slug, 'event_id' => $event->id, 'identifier' => 'p-1',
'firstname' => 'Hans', 'lastname' => 'Muster', 'participation_type' => 'participant',
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '04103', 'city' => 'Leipzig',
'email_1' => 'h@example.com', 'phone_1' => '123', 'payment_purpose' => 'Beitrag',
'eating_habit' => EatingHabit::EATING_HABIT_VEGAN,
'arrival_date' => '2026-09-01', 'departure_date' => '2026-09-05',
'arrival_eating' => 1, 'departure_eating' => 2, 'amount' => 0,
'created_at' => now(), 'updated_at' => now(),
]);
$event = $this->updateGroupType($event, ParticipantGroupType::ParentGroup);
$this->assertSame('local_group', $event->participant_group_type);
}
}
+107 -2
View File
@@ -2,20 +2,25 @@
namespace Tests\Feature;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckCommand;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckRequest;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\ParentGroup;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
@@ -55,6 +60,7 @@ class ShortSignUpTest extends TestCase
bool $swimmingPermissionRequired = true,
?float $amountReduced = 30.0,
?float $amountSolidarity = 70.0,
bool $cocCheckRequired = true,
): Event
{
$event = Event::create([
@@ -64,6 +70,7 @@ class ShortSignUpTest extends TestCase
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
'short_registration' => true, 'swimming_permission_required' => $swimmingPermissionRequired,
'coc_check_required' => $cocCheckRequired,
]);
if ($withFee) {
@@ -215,6 +222,43 @@ class ShortSignUpTest extends TestCase
$this->assertSame('maria@example.com', $response->participant->email_2);
}
public function test_volljaehrige_ohne_efz_pruefung_brauchen_kein_efz(): void
{
Http::fake();
$event = $this->createEvent(cocCheckRequired: false);
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $response->participant->efz_status);
$cocResponse = new CertificateOfConductionCheckCommand(
new CertificateOfConductionCheckRequest($response->participant)
)->execute();
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $cocResponse->status);
Http::assertNothingSent();
}
public function test_volljaehrige_mit_efz_pruefung_werden_geprueft(): void
{
putenv('COC_CHECK_URL=https://coc.test/check');
Http::fake(['*' => Http::response(['status' => '200/ok', 'efzStatus' => 'CHECKED_VALID'])]);
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_CHECKED, $response->participant->efz_status);
$cocResponse = new CertificateOfConductionCheckCommand(
new CertificateOfConductionCheckRequest($response->participant)
)->execute();
$this->assertSame(EfzStatus::EFZ_STATUS_CHECKED_VALID, $cocResponse->status);
Http::assertSentCount(1);
}
public function test_u18_uebernimmt_gewaehlte_badeerlaubnis(): void
{
$event = $this->createEvent();
@@ -294,7 +338,7 @@ class ShortSignUpTest extends TestCase
$this->assertSame(0, EventParticipant::count());
}
private function createLocalGroup(string $slug, string $name): Tenant
private function createLocalGroup(string $slug, string $name, ?ParentGroup $parentGroup = null): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
@@ -302,9 +346,70 @@ class ShortSignUpTest extends TestCase
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => false,
'parent_group_id' => $parentGroup?->id,
]);
}
public function test_bund_abfrage_ordnet_einzigen_bund_ohne_auswahl_zu(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$bdp = ParentGroup::where('name', 'BdP')->firstOrFail();
// Zwei Stämme desselben Bundes ergeben genau eine Option.
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', $bdp)->id,
$this->createLocalGroup('stamm-b', 'Stamm B', $bdp)->id,
]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame($bdp->id, $response->participant->parent_group_id);
$this->assertNull($response->participant->local_group);
$this->assertSame('BdP', $response->participant->toResource()->toArray(request())['participantGroup']);
}
public function test_bund_abfrage_uebernimmt_gewaehlten_bund(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$bdp = ParentGroup::where('name', 'BdP')->firstOrFail();
$vcp = ParentGroup::where('name', 'VCP')->firstOrFail();
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', $bdp)->id,
$this->createLocalGroup('stamm-b', 'Stamm B', $vcp)->id,
]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $vcp->id,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame($vcp->id, $response->participant->parent_group_id);
}
public function test_bund_abfrage_lehnt_nicht_beteiligten_bund_ab(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', ParentGroup::where('name', 'BdP')->firstOrFail())->id,
$this->createLocalGroup('stamm-b', 'Stamm B', ParentGroup::where('name', 'VCP')->firstOrFail())->id,
]);
$event->refresh();
// Der Bund existiert, aber kein teilnehmender Stamm gehört zu ihm.
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => ParentGroup::where('name', 'DPSG')->firstOrFail()->id,
]))->execute();
$this->assertFalse($response->success);
$this->assertSame('Bitte bei „Bund" eine Auswahl treffen.', $response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_einziger_stamm_wird_ohne_auswahl_zugeordnet(): void
{
$event = $this->createEvent();
@@ -377,7 +482,7 @@ class ShortSignUpTest extends TestCase
// Regressionswächter für Teilnehmerliste, Detailansicht und CSV-Export.
$array = $response->participant->toResource()->toArray(request());
$this->assertSame('Nicht im LV', $array['localgroup']);
$this->assertSame('Nicht im LV', $array['participantGroup']);
$this->assertSame('Erteilt', $array['swimmingPermission']);
$this->assertSame('Zugestimmt', $array['extendedFirstAid']);
$this->assertSame('Omnivor', $array['eatingHabit']);
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Die bei der Anmeldung gewählte Ernährungsweise muss gespeichert werden -- früher landete immer "Vegetarisch",
* weil der Controller ein verwaistes Formularfeld las.
*/
class SignUpEatingHabitTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
}
private function createEvent(array $eatingHabitSlugs): Event
{
$event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all());
return $event;
}
private function signUp(Event $event, string $eatingHabit): string
{
$request = new SignUpRequest(
$event, null, 'Hans', 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
$this->tenant, new \DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
'h@example.com', '123', null, null, null, null, null, null, null,
$eatingHabit, null, null, false, false, false, false, false,
new \DateTime('2026-08-01'), new \DateTime('2026-08-05'), 0, 0, null,
new Amount(50.0, ' Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, [],
);
$response = new SignUpCommand($request)->execute();
$this->assertTrue($response->success);
return $response->participant->fresh()->eating_habit;
}
public function test_chosen_vegan_is_stored(): void
{
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN, EatingHabit::EATING_HABIT_VEGETARIAN]);
$this->assertSame(EatingHabit::EATING_HABIT_VEGAN, $this->signUp($event, EatingHabit::EATING_HABIT_VEGAN));
}
public function test_chosen_vegetarian_is_stored(): void
{
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN, EatingHabit::EATING_HABIT_VEGETARIAN]);
$this->assertSame(EatingHabit::EATING_HABIT_VEGETARIAN, $this->signUp($event, EatingHabit::EATING_HABIT_VEGETARIAN));
}
public function test_habit_not_offered_by_event_falls_back_to_omnivor(): void
{
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGETARIAN]);
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $this->signUp($event, EatingHabit::EATING_HABIT_VEGAN));
}
public function test_empty_habit_falls_back_to_omnivor(): void
{
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN]);
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $this->signUp($event, ''));
}
}
+5 -2
View File
@@ -48,13 +48,16 @@ class SignUpPaymentOptionsTest extends TestCase
private function createEvent(): Event
{
return Event::create([
$event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$event->eatingHabits()->attach(EatingHabit::where('slug', EatingHabit::EATING_HABIT_VEGAN)->first()->id);
return $event;
}
private function signUpRequest(Event $event, array $paymentOptions): SignUpRequest
@@ -63,7 +66,7 @@ class SignUpPaymentOptionsTest extends TestCase
$event, null, 'Hans', 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
$this->tenant, new \DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
'h@example.com', '123', null, null, null, null, null, null, null,
'vegan', null, null, false, false, false, false, false,
EatingHabit::EATING_HABIT_VEGAN, null, null, false, false, false, false, false,
new \DateTime('2026-08-01'), new \DateTime('2026-08-05'), 0, 0, null,
new Amount(50.0, ' Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $paymentOptions,
);
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more