Support oft parent groups

This commit is contained in:
2026-09-29 14:35:33 +02:00
parent c4ca101594
commit a5e4e55df5
86 changed files with 1936 additions and 80 deletions
+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);
}
}
+65 -2
View File
@@ -7,10 +7,12 @@ 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;
@@ -294,7 +296,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 +304,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 +440,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']);