Files
crewli/api/database/seeders/DevSeeder.php
bert.hausmans a92ddc48ec refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:38:08 +02:00

1472 lines
89 KiB
PHP

<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\CrowdListType;
use App\Enums\ShiftAssignmentStatus;
use App\Models\Company;
use App\Models\CrowdList;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Location;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\TimeSlot;
use App\Models\User;
use App\Models\UserOrganisationTag;
use App\Models\VolunteerAvailability;
use App\Support\ActivityLog;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class DevSeeder extends Seeder
{
private Organisation $org;
/** @var array<string, User> */
private array $users = [];
/** @var array<string, CrowdType> */
private array $crowdTypes = [];
/** @var array<string, Company> */
private array $companies = [];
/** @var array<string, \App\Models\PersonTag> */
private array $personTags = [];
public function run(): void
{
$this->call(RoleSeeder::class);
// Suppress all activity-log writes during bulk fixture generation.
// Hundreds of logSchemaChange/logFieldChange + Organisation-trait
// entries would otherwise flood activity_log without useful value.
ActivityLog::suppressed(function (): void {
$this->seedOrganisation();
$this->seedEchtFeesten();
$this->seedBraderie();
$this->seedIJsbaan();
$this->seedKoningsdag();
$this->seedNachtVanDeKaap();
});
}
// =========================================================================
// Organisation: Stichting Feestfabriek
// =========================================================================
private function seedOrganisation(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding organisation: Stichting Feestfabriek...');
$this->org = Organisation::create([
'name' => 'Stichting Feestfabriek',
'slug' => 'stichting-feestfabriek',
'billing_status' => 'active',
'settings' => [],
]);
// ── Users (8) ──
$usersData = [
['email' => 'admin@crewli.test', 'first_name' => 'Super', 'last_name' => 'Admin', 'app_role' => 'super_admin', 'org_role' => 'org_admin', 'date_of_birth' => '1985-01-15'],
['email' => 'bert@feestfabriek.nl', 'first_name' => 'Bert', 'last_name' => 'Hausmans', 'org_role' => 'org_admin', 'date_of_birth' => '1990-06-28'],
['email' => 'lisa@feestfabriek.nl', 'first_name' => 'Lisa', 'last_name' => 'van den Berg', 'org_role' => 'org_member', 'date_of_birth' => '1993-05-12'],
['email' => 'ahmed@feestfabriek.nl', 'first_name' => 'Ahmed', 'last_name' => 'Yilmaz', 'org_role' => 'org_member', 'date_of_birth' => '1989-09-03'],
['email' => 'sara@feestfabriek.nl', 'first_name' => 'Sara', 'last_name' => 'de Groot', 'org_role' => 'org_member', 'date_of_birth' => '1991-08-24'],
['email' => 'tom@feestfabriek.nl', 'first_name' => 'Tom', 'last_name' => 'Visser', 'org_role' => 'org_member', 'date_of_birth' => '1994-11-07'],
['email' => 'nina@feestfabriek.nl', 'first_name' => 'Nina', 'last_name' => 'Jansen', 'org_role' => 'org_member', 'date_of_birth' => '1996-02-14'],
['email' => 'mark@feestfabriek.nl', 'first_name' => 'Mark', 'last_name' => 'de Boer', 'org_role' => 'org_member', 'date_of_birth' => '1988-03-17'],
];
foreach ($usersData as $data) {
$user = User::create([
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'email' => $data['email'],
'date_of_birth' => $data['date_of_birth'] ?? null,
'password' => Hash::make('password'),
]);
if (isset($data['app_role'])) {
$user->assignRole($data['app_role']);
}
$this->org->users()->attach($user, ['role' => $data['org_role']]);
$this->users[$data['email']] = $user;
}
// ── Companies (6) ──
$companiesData = [
['name' => 'Tap & Co Horeca', 'type' => 'supplier', 'contact_first_name' => 'Jan', 'contact_last_name' => 'Tapper', 'contact_email' => 'jan@tapco.nl', 'contact_phone' => '+31612340001'],
['name' => 'SecureEvent BV', 'type' => 'supplier', 'contact_first_name' => 'Klaas', 'contact_last_name' => 'Veilig', 'contact_email' => 'klaas@secureevent.nl', 'contact_phone' => '+31612340002'],
['name' => 'Podiumtechniek Rijnmond', 'type' => 'supplier', 'contact_first_name' => 'Pieter', 'contact_last_name' => 'Geluid', 'contact_email' => 'pieter@podiumtechniek.nl', 'contact_phone' => '+31612340003'],
['name' => 'Brouwerij De Schelde', 'type' => 'partner', 'contact_first_name' => 'Eva', 'contact_last_name' => 'Brouwer', 'contact_email' => 'eva@brouwerijdeschelde.nl', 'contact_phone' => '+31612340004'],
['name' => 'Van Dijk Catering', 'type' => 'supplier', 'contact_first_name' => 'Maria', 'contact_last_name' => 'van Dijk', 'contact_email' => 'maria@vandijkcatering.nl', 'contact_phone' => '+31612340005'],
['name' => 'Rotterdam Festivals', 'type' => 'agency', 'contact_first_name' => 'Femke', 'contact_last_name' => 'de Wit', 'contact_email' => 'femke@rotterdamfestivals.nl', 'contact_phone' => '+31612340006'],
];
foreach ($companiesData as $data) {
$company = Company::create(['organisation_id' => $this->org->id, ...$data]);
$this->companies[$data['name']] = $company;
}
// ── Crowd Types (7) ──
$crowdTypesData = [
['name' => 'Vrijwilliger', 'system_type' => 'VOLUNTEER', 'color' => '#4CAF50', 'icon' => 'tabler-heart-handshake'],
['name' => 'Medewerker', 'system_type' => 'CREW', 'color' => '#2196F3', 'icon' => 'tabler-id-badge-2'],
['name' => 'Artiest', 'system_type' => 'ARTIST', 'color' => '#9C27B0', 'icon' => 'tabler-microphone-2'],
['name' => 'Pers', 'system_type' => 'PRESS', 'color' => '#FF9800', 'icon' => 'tabler-camera'],
['name' => 'Gast', 'system_type' => 'GUEST', 'color' => '#607D8B', 'icon' => 'tabler-ticket'],
['name' => 'Leverancier', 'system_type' => 'SUPPLIER', 'color' => '#795548', 'icon' => 'tabler-truck-delivery'],
['name' => 'Partner', 'system_type' => 'PARTNER', 'color' => '#FFC107', 'icon' => 'tabler-affiliate'],
];
foreach ($crowdTypesData as $data) {
$ct = CrowdType::create(['organisation_id' => $this->org->id, ...$data, 'is_active' => true]);
$this->crowdTypes[$data['system_type']] = $ct;
}
// ── Person Tags (10) ──
$personTagsData = [
['name' => 'Tapper', 'category' => 'Horeca', 'icon' => 'tabler-beer', 'color' => '#FF9800', 'is_active' => true, 'sort_order' => 1],
['name' => 'Barista', 'category' => 'Horeca', 'icon' => 'tabler-coffee', 'color' => '#795548', 'is_active' => true, 'sort_order' => 2],
['name' => 'EHBO', 'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'color' => '#F44336', 'is_active' => true, 'sort_order' => 3],
['name' => 'BHV', 'category' => 'Veiligheid', 'icon' => 'tabler-fire-extinguisher', 'color' => '#E91E63', 'is_active' => true, 'sort_order' => 4],
['name' => 'Rijbewijs B', 'category' => 'Logistiek', 'icon' => 'tabler-car', 'color' => '#607D8B', 'is_active' => true, 'sort_order' => 5],
['name' => 'Heftruck', 'category' => 'Logistiek', 'icon' => 'tabler-forklift', 'color' => '#9E9E9E', 'is_active' => true, 'sort_order' => 6],
['name' => 'Duits', 'category' => 'Taal', 'icon' => 'tabler-flag', 'color' => '#000000', 'is_active' => true, 'sort_order' => 7],
['name' => 'Engels', 'category' => 'Taal', 'icon' => 'tabler-flag', 'color' => '#1565C0', 'is_active' => true, 'sort_order' => 8],
['name' => 'Podiumervaring', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'color' => '#9C27B0', 'is_active' => true, 'sort_order' => 9],
['name' => 'Teamleider', 'category' => 'Rol', 'icon' => 'tabler-crown', 'color' => '#FFC107', 'is_active' => false, 'sort_order' => 10],
];
foreach ($personTagsData as $data) {
$tag = $this->org->personTags()->create($data);
$this->personTags[$data['name']] = $tag;
}
// ── Form Builder: system templates (new structure) ──
$templatesCreated = FormBuilderDevSeeder::seedSystemTemplates($this->org);
$this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates created");
});
}
// =========================================================================
// Event 1: Echt Feesten 2026 (festival, registration_open, 150 persons)
// =========================================================================
private function seedEchtFeesten(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding Echt Feesten 2026...');
// ── Event hierarchy ──
$festival = Event::create([
'organisation_id' => $this->org->id,
'name' => 'Echt Feesten 2026',
'slug' => 'echt-feesten-2026',
'start_date' => '2026-07-10',
'end_date' => '2026-07-12',
'timezone' => 'Europe/Amsterdam',
'status' => 'registration_open',
'event_type' => 'festival',
'event_type_label' => 'Festival',
'sub_event_label' => 'Programmaonderdeel',
'registration_welcome_text' => 'Wij zoeken enthousiaste vrijwilligers voor Echt Feesten 2026! Word onderdeel van ons team en beleef het festival van de andere kant. Gratis toegang, maaltijden, en een onvergetelijke ervaring.',
]);
$vrijdag = Event::create([
'organisation_id' => $this->org->id,
'parent_event_id' => $festival->id,
'name' => 'Dag 1 — Vrijdag',
'slug' => 'echt-feesten-2026-vrijdag',
'start_date' => '2026-07-10',
'end_date' => '2026-07-10',
'timezone' => 'Europe/Amsterdam',
'status' => 'registration_open',
'event_type' => 'event',
]);
$zaterdag = Event::create([
'organisation_id' => $this->org->id,
'parent_event_id' => $festival->id,
'name' => 'Dag 2 — Zaterdag',
'slug' => 'echt-feesten-2026-zaterdag',
'start_date' => '2026-07-11',
'end_date' => '2026-07-11',
'timezone' => 'Europe/Amsterdam',
'status' => 'registration_open',
'event_type' => 'event',
]);
$zondag = Event::create([
'organisation_id' => $this->org->id,
'parent_event_id' => $festival->id,
'name' => 'Dag 3 — Zondag',
'slug' => 'echt-feesten-2026-zondag',
'start_date' => '2026-07-12',
'end_date' => '2026-07-12',
'timezone' => 'Europe/Amsterdam',
'status' => 'published',
'event_type' => 'event',
]);
$subEvents = ['vrijdag' => $vrijdag, 'zaterdag' => $zaterdag, 'zondag' => $zondag];
// Attach event-level user roles
$eventRoles = [
'lisa@feestfabriek.nl' => 'event_manager',
'ahmed@feestfabriek.nl' => 'event_manager',
'sara@feestfabriek.nl' => 'volunteer_coordinator',
'tom@feestfabriek.nl' => 'staff_coordinator',
'nina@feestfabriek.nl' => 'artist_manager',
];
foreach ($eventRoles as $email => $role) {
$festival->users()->attach($this->users[$email], ['role' => $role]);
}
// ── Locations (5, on parent) ──
$locations = [];
$locationsData = [
'hoofdpodium' => ['name' => 'Hoofdpodium', 'address' => 'Parkweg 1, Echt', 'lat' => 51.1039, 'lng' => 5.8718],
'theatertent' => ['name' => 'Theatertent', 'address' => 'Parkweg 1A, Echt', 'lat' => 51.1041, 'lng' => 5.8725],
'ingang' => ['name' => 'Festivalterrein Ingang', 'address' => 'Parkweg 2, Echt', 'lat' => 51.1035, 'lng' => 5.8710],
'backstage' => ['name' => 'Backstage Area', 'address' => 'Parkweg 1B, Echt', 'lat' => 51.1043, 'lng' => 5.8720],
'camping' => ['name' => 'Camping', 'address' => 'Kampeerweg 5, Echt', 'lat' => 51.1050, 'lng' => 5.8740],
];
foreach ($locationsData as $key => $data) {
$locations[$key] = Location::create(['event_id' => $festival->id, ...$data]);
}
// ── Festival-level sections (4) ──
$ehbo = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'EHBO', 'type' => 'cross_event',
'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'sort_order' => 1,
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
'show_in_registration' => false,
]);
$security = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Security', 'type' => 'cross_event',
'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2,
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
'show_in_registration' => false,
]);
$terreinploeg = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Terreinploeg', 'type' => 'standard',
'category' => 'Productie', 'icon' => 'tabler-shovel', 'sort_order' => 3,
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
'show_in_registration' => false,
]);
$accreditatiebalie = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Accreditatiebalie', 'type' => 'cross_event',
'category' => 'Ontvangst', 'icon' => 'tabler-id-badge', 'sort_order' => 4,
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
'show_in_registration' => false,
]);
// Parent-level standard section that mirrors a child name — exercises
// PublicFormController::sections() dedup across parent+child.
FestivalSection::create([
'event_id' => $festival->id,
'name' => 'Hoofdpodium Bar',
'type' => 'standard',
'category' => 'Bar',
'icon' => 'tabler-beer',
'sort_order' => 5,
'responder_self_checkin' => true,
'crew_auto_accepts' => true,
'show_in_registration' => true,
'registration_description' => 'Overkoepelende barinzet — je draait mee op het hoofdpodium over alle dagen',
]);
// ── Sub-event sections (5 per sub-event) ──
$sectionDefs = [
'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tap bier en drankjes voor festivalgangers bij het hoofdpodium'],
'theaterbar' => ['name' => 'Theatertent Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Bediening in de overdekte theatertent'],
'hospitality' => ['name' => 'Backstage Hospitality', 'category' => 'Hospitality', 'icon' => 'tabler-armchair', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Ontvang en begeleid artiesten en gasten backstage'],
'podiumtechniek' => ['name' => 'Podiumtechniek', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Help met geluid- en lichttechniek bij de podia'],
'ingang' => ['name' => 'Ingang & Tickets', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Verwelkom bezoekers en scan tickets bij de ingang'],
];
$sections = [];
foreach ($subEvents as $dayKey => $subEvent) {
$order = 1;
foreach ($sectionDefs as $key => $def) {
$sections["{$dayKey}_{$key}"] = FestivalSection::create([
'event_id' => $subEvent->id,
'name' => $def['name'],
'type' => 'standard',
'category' => $def['category'],
'icon' => $def['icon'],
'sort_order' => $order++,
'responder_self_checkin' => true,
'crew_auto_accepts' => $def['crew_auto_accepts'],
'show_in_registration' => $def['show_in_registration'] ?? false,
'registration_description' => $def['registration_description'] ?? null,
]);
}
}
// ── Festival-level time slots (CREW only — operational planning) ──
// No VOLUNTEER slots at festival level — cross_event sections use sub-event time slots
$fSlots = [];
$fSlots['opbouw1'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw vrijdag', 'person_type' => 'CREW', 'date' => '2026-07-09', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]);
$fSlots['opbouw2'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw zaterdag', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '08:00', 'end_time' => '14:00', 'duration_hours' => 6.00]);
$fSlots['nacht_vr'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtsecurity vr→za', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '23:00', 'end_time' => '07:00', 'duration_hours' => 8.00]);
$fSlots['nacht_za'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtsecurity za→zo', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '23:00', 'end_time' => '07:00', 'duration_hours' => 8.00]);
$fSlots['afbraak'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Afbraak', 'person_type' => 'CREW', 'date' => '2026-07-13', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]);
// Parent-level VOLUNTEER slots — ensures PublicFormController::timeSlots
// surfaces both parent and child events in the merged response.
$fSlots['vol_opbouw_vr'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijwilligers opbouw vrijdag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '09:00', 'end_time' => '12:00', 'duration_hours' => 3.00]);
$fSlots['vol_afbraak_zo'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijwilligers afbraak zondag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-13', 'start_time' => '10:00', 'end_time' => '16:00', 'duration_hours' => 6.00]);
// ── Sub-event time slots (program-specific) ──
$ts = [];
// Dag 1 — Vrijdag
$ts['vr_middag'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '12:00', 'end_time' => '18:00', 'duration_hours' => 6.00]);
$ts['vr_avond'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]);
$ts['vr_crew'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag avond — crew', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '17:00', 'end_time' => '03:00', 'duration_hours' => 10.00]);
$ts['vr_pers'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag pers', 'person_type' => 'PRESS', 'date' => '2026-07-10', 'start_time' => '16:00', 'end_time' => '22:00', 'duration_hours' => 6.00]);
// Dag 2 — Zaterdag
$ts['za_middag'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '12:00', 'end_time' => '18:00', 'duration_hours' => 6.00]);
$ts['za_avond'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]);
$ts['za_crew'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag avond — crew', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '17:00', 'end_time' => '03:00', 'duration_hours' => 10.00]);
// Dag 3 — Zondag
$ts['zo_middag'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]);
$ts['zo_avond'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '18:00', 'end_time' => '22:00', 'duration_hours' => 4.00]);
// ── Shifts (~55) ──
$allShifts = [];
$s = []; // named shifts for explicit assignments
// EHBO (cross_event): shifts use sub-event time slots (cross_event exception)
// + 1 festival-level shift for opbouw duties
$s['ehbo_opbouw'] = Shift::create([
'festival_section_id' => $ehbo->id, 'time_slot_id' => $fSlots['opbouw1']->id,
'title' => 'EHBO Post', 'slots_total' => 2, 'slots_open_for_claiming' => 0, 'status' => 'open',
]);
$allShifts[] = $s['ehbo_opbouw'];
foreach (['vr_middag', 'vr_avond', 'za_middag', 'za_avond', 'zo_middag'] as $key) {
$shift = Shift::create([
'festival_section_id' => $ehbo->id,
'time_slot_id' => $ts[$key]->id,
'title' => 'EHBO Post',
'slots_total' => $key === 'za_middag' ? 4 : 3,
'slots_open_for_claiming' => $key === 'za_middag' ? 0 : 2,
'status' => 'open',
]);
$allShifts[] = $shift;
$s["ehbo_{$key}"] = $shift;
}
// Security (cross_event): festival night shifts + sub-event crew shifts
foreach (['nacht_vr', 'nacht_za'] as $key) {
$shift = Shift::create([
'festival_section_id' => $security->id,
'time_slot_id' => $fSlots[$key]->id,
'title' => 'Beveiliger',
'slots_total' => 6,
'slots_open_for_claiming' => 0,
'status' => 'open',
]);
$allShifts[] = $shift;
$s["security_{$key}"] = $shift;
}
// Security shifts using sub-event CREW time slots (cross_event exception)
foreach (['vr_crew', 'za_crew'] as $key) {
$shift = Shift::create([
'festival_section_id' => $security->id,
'time_slot_id' => $ts[$key]->id,
'title' => 'Beveiliger',
'slots_total' => 4,
'slots_open_for_claiming' => 0,
'status' => 'open',
]);
$allShifts[] = $shift;
$s["security_{$key}"] = $shift;
}
// Terreinploeg (standard on festival): shifts use festival time slots only
foreach (['opbouw1', 'opbouw2', 'afbraak'] as $key) {
$shift = Shift::create([
'festival_section_id' => $terreinploeg->id,
'time_slot_id' => $fSlots[$key]->id,
'title' => 'Terreinmedewerker',
'slots_total' => $key === 'afbraak' ? 20 : 15,
'slots_open_for_claiming' => $key === 'afbraak' ? 16 : 12,
'status' => $key === 'afbraak' ? 'draft' : 'open',
]);
$allShifts[] = $shift;
$s["terrein_{$key}"] = $shift;
}
// Accreditatiebalie (cross_event): shifts use sub-event time slots
foreach (['vr_middag', 'za_middag', 'zo_middag', 'za_avond'] as $key) {
$shift = Shift::create([
'festival_section_id' => $accreditatiebalie->id,
'time_slot_id' => $ts[$key]->id,
'title' => 'Accreditatiemedewerker',
'slots_total' => 3,
'slots_open_for_claiming' => 2,
'status' => 'open',
]);
$allShifts[] = $shift;
$s["accred_{$key}"] = $shift;
}
// Sub-event shifts
$slotMap = [
'vrijdag' => ['vr_middag', 'vr_avond'],
'zaterdag' => ['za_middag', 'za_avond'],
'zondag' => ['zo_middag', 'zo_avond'],
];
$locationMap = [
'hoofdbar' => 'hoofdpodium',
'theaterbar' => 'theatertent',
'hospitality' => 'backstage',
'podiumtechniek' => 'hoofdpodium',
'ingang' => 'ingang',
];
$titleMap = [
'hoofdbar' => 'Tapper',
'theaterbar' => 'Tapper',
'hospitality' => 'Hospitality',
'podiumtechniek' => 'Stagehand',
'ingang' => 'Ticketcontrole',
];
foreach ($slotMap as $dayKey => $slotKeys) {
foreach (array_keys($sectionDefs) as $secKey) {
foreach ($slotKeys as $slotKey) {
$sectionModel = $sections["{$dayKey}_{$secKey}"];
$slotsTotal = match ($secKey) {
'hoofdbar', 'theaterbar' => rand(8, 12),
'ingang' => rand(6, 8),
default => rand(3, 4),
};
$slotsOpen = match ($secKey) {
'hoofdbar', 'theaterbar' => rand(6, 10),
'ingang' => rand(4, 6),
default => 0,
};
$isDraft = $dayKey === 'zondag' && $slotKey === 'zo_avond';
$shift = Shift::create([
'festival_section_id' => $sectionModel->id,
'time_slot_id' => $ts[$slotKey]->id,
'location_id' => $locations[$locationMap[$secKey]]->id,
'title' => $titleMap[$secKey],
'slots_total' => $slotsTotal,
'slots_open_for_claiming' => $slotsOpen,
'status' => $isDraft ? 'draft' : 'open',
'allow_overlap' => $secKey === 'podiumtechniek',
]);
$allShifts[] = $shift;
$s["{$secKey}_{$slotKey}"] = $shift;
}
}
}
$shiftCount = count($allShifts);
$this->command->info(" {$shiftCount} shifts created");
// ── Named persons (30) ──
$vol = $this->crowdTypes['VOLUNTEER']->id;
$crew = $this->crowdTypes['CREW']->id;
$press = $this->crowdTypes['PRESS']->id;
$guest = $this->crowdTypes['GUEST']->id;
$supplier = $this->crowdTypes['SUPPLIER']->id;
// Create user accounts for volunteers who need user_id links
$volunteerUsers = [];
foreach ([
['first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@gmail.com'],
['first_name' => 'Ahmed', 'last_name' => 'Hassan', 'email' => 'ahmed.h@gmail.com'],
['first_name' => 'Tom', 'last_name' => 'Visser', 'email' => 'tom.visser@gmail.com'],
['first_name' => 'Lotte', 'last_name' => 'de Jong', 'email' => 'lotte@gmail.com'],
['first_name' => 'Pieter', 'last_name' => 'Geluid', 'email' => 'pieter@podiumtechniek.nl'],
] as $u) {
$volunteerUsers[$u['email']] = User::create([
'first_name' => $u['first_name'],
'last_name' => $u['last_name'],
'email' => $u['email'],
'password' => Hash::make('password'),
]);
}
// 15 named volunteers
$jan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['jan@gmail.com']->id, 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@gmail.com', 'phone' => '+31612345001', 'status' => 'approved', 'date_of_birth' => '1995-03-15']);
$lisaB = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Lisa', 'last_name' => 'Bakker', 'email' => 'lisa.bakker@hotmail.com', 'phone' => '+31612345002', 'status' => 'approved', 'date_of_birth' => '1998-07-22']);
$ahmedP = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['ahmed.h@gmail.com']->id, 'first_name' => 'Ahmed', 'last_name' => 'Hassan', 'email' => 'ahmed.h@gmail.com', 'phone' => '+31612345003', 'status' => 'approved', 'date_of_birth' => '1992-11-08']);
$saraJ = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sara', 'last_name' => 'Jansen', 'email' => 'sara.j@outlook.com', 'phone' => '+31612345004', 'status' => 'approved', 'date_of_birth' => '2000-01-30']);
$tomV = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['tom.visser@gmail.com']->id, 'first_name' => 'Tom', 'last_name' => 'Visser', 'email' => 'tom.visser@gmail.com', 'phone' => '+31612345005', 'status' => 'approved', 'date_of_birth' => '1997-06-14']);
$fatima = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Fatima', 'last_name' => 'El Amrani', 'email' => 'fatima@gmail.com', 'phone' => '+31612345006', 'status' => 'approved', 'date_of_birth' => '1996-05-20']);
$daan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Daan', 'last_name' => 'Smit', 'email' => 'daan.smit@gmail.com', 'phone' => '+31612345007', 'status' => 'pending', 'date_of_birth' => '1993-08-11']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sophie', 'last_name' => 'Mulder', 'email' => 'sophie.m@hotmail.com', 'phone' => '+31612345008', 'status' => 'pending', 'date_of_birth' => '2001-02-28']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Jesse', 'last_name' => 'van Dijk', 'email' => 'jesse@gmail.com', 'phone' => '+31612345009', 'status' => 'applied', 'date_of_birth' => '1999-10-05']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Noa', 'last_name' => 'Hendriks', 'email' => 'noa.h@outlook.com', 'phone' => '+31612345010', 'status' => 'applied', 'date_of_birth' => '2000-07-19']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Kevin', 'last_name' => 'Bos', 'email' => 'kevin.bos@gmail.com', 'phone' => '+31612345011', 'status' => 'rejected', 'admin_notes' => 'Vorig jaar no-show', 'date_of_birth' => '1994-12-01']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Priya', 'last_name' => 'Sharma', 'email' => 'priya@gmail.com', 'phone' => '+31612345012', 'status' => 'invited', 'date_of_birth' => '1998-03-27']);
$lotte = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['lotte@gmail.com']->id, 'first_name' => 'Lotte', 'last_name' => 'de Jong', 'email' => 'lotte@gmail.com', 'phone' => '+31612345013', 'status' => 'approved', 'date_of_birth' => '1994-09-25']);
$robin = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Robin', 'last_name' => 'Peters', 'email' => 'robin.p@hotmail.com', 'phone' => '+31612345014', 'status' => 'approved', 'date_of_birth' => '1991-12-03']);
$emma = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Emma', 'last_name' => 'Willems', 'email' => 'emma.w@gmail.com', 'phone' => '+31612345015', 'status' => 'no_show', 'is_blacklisted' => true, 'date_of_birth' => '1999-04-17']);
// 6 named crew
$klaas = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['SecureEvent BV']->id, 'first_name' => 'Klaas', 'last_name' => 'Veilig Jr.', 'email' => 'klaas.jr@secureevent.nl', 'phone' => '+31612345016', 'status' => 'approved']);
$dennis = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['SecureEvent BV']->id, 'first_name' => 'Dennis', 'last_name' => 'Schild', 'email' => 'dennis@secureevent.nl', 'phone' => '+31612345017', 'status' => 'approved']);
$pieter = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['Podiumtechniek Rijnmond']->id, 'user_id' => $volunteerUsers['pieter@podiumtechniek.nl']->id, 'first_name' => 'Pieter', 'last_name' => 'Geluid', 'email' => 'pieter@podiumtechniek.nl', 'phone' => '+31612345018', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['Podiumtechniek Rijnmond']->id, 'first_name' => 'Marco', 'last_name' => 'Licht', 'email' => 'marco@podiumtechniek.nl', 'phone' => '+31612345019', 'status' => 'approved']);
$eva = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['Brouwerij De Schelde']->id, 'first_name' => 'Eva', 'last_name' => 'Brouwer', 'email' => 'eva@brouwerijdeschelde.nl', 'phone' => '+31612345020', 'status' => 'approved']);
$maria = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['Van Dijk Catering']->id, 'first_name' => 'Maria', 'last_name' => 'van Dijk', 'email' => 'maria@vandijkcatering.nl', 'phone' => '+31612345021', 'status' => 'approved']);
// 3 named press
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $press, 'first_name' => 'Joris', 'last_name' => 'van Laar', 'email' => 'joris@pers.nl', 'phone' => '+31612345022', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $press, 'first_name' => 'Tamara', 'last_name' => 'Smeets', 'email' => 'tamara@pers.nl', 'phone' => '+31612345023', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $press, 'first_name' => 'Sven', 'last_name' => 'Pieterse', 'email' => 'sven@pers.nl', 'phone' => '+31612345024', 'status' => 'pending']);
// 4 named guests
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $guest, 'first_name' => 'Jan', 'last_name' => 'Slagter', 'email' => 'burgemeester@echt.nl', 'phone' => '+31612345025', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $guest, 'first_name' => 'Petra', 'last_name' => 'Kamps', 'email' => 'wethouder@echt.nl', 'phone' => '+31612345026', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $guest, 'first_name' => 'Jan', 'last_name' => 'Sponsor', 'email' => 'sponsor@bedrijf.nl', 'phone' => '+31612345027', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $guest, 'first_name' => 'Gast', 'last_name' => 'van Artiest', 'email' => 'artiestgast@gmail.com', 'phone' => '+31612345028', 'status' => 'invited']);
// 2 named suppliers
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $supplier, 'company_id' => $this->companies['Tap & Co Horeca']->id, 'first_name' => 'Hans', 'last_name' => 'Tapper', 'email' => 'hans@tapco.nl', 'phone' => '+31612345029', 'status' => 'approved']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $supplier, 'company_id' => $this->companies['Van Dijk Catering']->id, 'first_name' => 'Frank', 'last_name' => 'van Dijk', 'email' => 'frank@vandijkcatering.nl', 'phone' => '+31612345030', 'status' => 'pending']);
// ── Factory persons (120) ──
$companyIds = collect($this->companies)->pluck('id');
// 80 factory volunteers
$factoryVolunteers = collect();
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(55)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $vol]));
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(10)->create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'status' => 'pending']));
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(8)->create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'status' => 'applied']));
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(4)->create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'status' => 'invited']));
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'status' => 'rejected']));
$factoryVolunteers = $factoryVolunteers->merge(Person::factory()->count(1)->create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'status' => 'no_show']));
// 19 factory crew
Person::factory()->count(12)->approved()
->sequence(fn () => ['company_id' => $companyIds->random()])
->create(['event_id' => $festival->id, 'crowd_type_id' => $crew]);
Person::factory()->count(5)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $crew]);
Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'status' => 'pending']);
// 7 factory press
Person::factory()->count(5)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $press]);
Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $press, 'status' => 'pending']);
// 8 factory guests
Person::factory()->count(6)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $guest]);
Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $guest, 'status' => 'invited']);
// 6 factory suppliers
Person::factory()->count(4)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $supplier]);
Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $supplier, 'status' => 'pending']);
// ── Identity match test data ──
// Persons whose emails match org member accounts (for email-based identity matching)
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Lisa', 'last_name' => 'van den Berg', 'email' => 'lisa@feestfabriek.nl', 'phone' => '+31612345040', 'status' => 'applied', 'date_of_birth' => '1993-05-12']);
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sara', 'last_name' => 'de Groot', 'email' => 'sara@feestfabriek.nl', 'phone' => '+31612345041', 'status' => 'pending', 'date_of_birth' => '1991-08-24']);
// Person with fuzzy name match to org member "Nina Jansen" (different email)
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Nena', 'last_name' => 'Jansen', 'email' => 'nena.jansen@gmail.com', 'phone' => '+31612345042', 'status' => 'pending', 'date_of_birth' => null]);
// Person with fuzzy name match + DOB match to org member "Mark de Boer" (DOB already set in user creation)
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Marc', 'last_name' => 'de Boer', 'email' => 'marc.deboer@gmail.com', 'phone' => '+31612345043', 'status' => 'pending', 'date_of_birth' => '1988-03-17']);
// Person with unique email (no match expected)
Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Unique', 'last_name' => 'Persoon', 'email' => 'unique.persoon@nowhere.test', 'phone' => '+31612345044', 'status' => 'pending']);
$linked = $this->linkUsersToApprovedPersons($festival);
$personCount = Person::where('event_id', $festival->id)->count();
$this->command->info(" {$personCount} persons created ({$linked} user accounts linked)");
// ── Named shift assignments (22) ──
$tom = $this->users['tom@feestfabriek.nl'];
$namedAssignments = [
// Jan de Vries: 3 bar shifts
['person' => $jan, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
['person' => $jan, 'shift' => 'hoofdbar_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => false],
['person' => $jan, 'shift' => 'hoofdbar_za_middag', 'status' => ShiftAssignmentStatus::APPROVED],
// Lisa Bakker
['person' => $lisaB, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
['person' => $lisaB, 'shift' => 'hoofdbar_za_middag', 'status' => ShiftAssignmentStatus::PENDING_APPROVAL],
['person' => $lisaB, 'shift' => 'theaterbar_zo_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
// Ahmed Hassan: 4 EHBO shifts (cross_event section using sub-event time slots)
['person' => $ahmedP, 'shift' => 'ehbo_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED],
['person' => $ahmedP, 'shift' => 'ehbo_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED],
['person' => $ahmedP, 'shift' => 'ehbo_za_middag', 'status' => ShiftAssignmentStatus::APPROVED],
['person' => $ahmedP, 'shift' => 'ehbo_za_avond', 'status' => ShiftAssignmentStatus::APPROVED],
// Sara Jansen
['person' => $saraJ, 'shift' => 'theaterbar_vr_avond', 'status' => ShiftAssignmentStatus::PENDING_APPROVAL],
// Tom Visser (volunteer)
['person' => $tomV, 'shift' => 'podiumtechniek_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED],
// Fatima
['person' => $fatima, 'shift' => 'hoofdbar_za_avond', 'status' => ShiftAssignmentStatus::APPROVED],
// Lotte: terreinploeg
['person' => $lotte, 'shift' => 'terrein_opbouw1', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
['person' => $lotte, 'shift' => 'terrein_afbraak', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
// Robin
['person' => $robin, 'shift' => 'theaterbar_za_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true],
// Klaas: security, assigned by Tom
['person' => $klaas, 'shift' => 'security_nacht_vr', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id],
// Dennis: security
['person' => $dennis, 'shift' => 'security_nacht_za', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id],
// Daan: rejected
['person' => $daan, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::REJECTED, 'reason' => 'Nog niet goedgekeurd'],
// Emma: cancelled
['person' => $emma, 'shift' => 'ingang_vr_middag', 'status' => ShiftAssignmentStatus::CANCELLED],
// Pieter: podiumtechniek with overlap
['person' => $pieter, 'shift' => 'podiumtechniek_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED],
['person' => $pieter, 'shift' => 'podiumtechniek_za_avond', 'status' => ShiftAssignmentStatus::APPROVED],
];
$usedPersonSlots = []; // track person_id => [time_slot_ids] for factory assignments
foreach ($namedAssignments as $a) {
$shift = $s[$a['shift']];
$isApproved = $a['status'] === ShiftAssignmentStatus::APPROVED;
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $a['person']->id,
'time_slot_id' => $shift->time_slot_id,
'status' => $a['status'],
'auto_approved' => $a['auto'] ?? false,
'assigned_by' => $a['assigned_by'] ?? null,
'assigned_at' => now(),
'approved_at' => $isApproved ? now() : null,
'rejection_reason' => $a['reason'] ?? null,
]);
$usedPersonSlots[$a['person']->id][] = $shift->time_slot_id;
}
// ── Intentional overbooking for UI testing ──
$overbookPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->whereNotIn('id', array_keys($usedPersonSlots))
->limit(20)
->get();
// EHBO Vrijdag Middag: slots_total=3, target 4 approved (1 over)
// Already has 1 named approved (Ahmed). Add 3 more = 4 total.
$ehboOverbookTarget = 3;
$ehboShift = $s['ehbo_vr_middag'];
foreach ($overbookPersons->splice(0, $ehboOverbookTarget) as $person) {
ShiftAssignment::create([
'shift_id' => $ehboShift->id,
'person_id' => $person->id,
'time_slot_id' => $ehboShift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => false,
'assigned_at' => now(),
'approved_at' => now(),
]);
$usedPersonSlots[$person->id][] = $ehboShift->time_slot_id;
}
// Terreinploeg Opbouw Dag 1: slots_total=15, target 17 approved (2 over)
// Already has 1 named approved (Lotte). Add 16 more = 17 total.
$terreinOverbookTarget = 16;
$terreinShift = $s['terrein_opbouw1'];
$terreinOverbookPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->whereNotIn('id', collect($usedPersonSlots)->keys()->filter(
fn ($pid) => in_array($terreinShift->time_slot_id, $usedPersonSlots[$pid] ?? []),
)->values())
->limit($terreinOverbookTarget)
->get();
foreach ($terreinOverbookPersons as $person) {
ShiftAssignment::create([
'shift_id' => $terreinShift->id,
'person_id' => $person->id,
'time_slot_id' => $terreinShift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => true,
'assigned_at' => now(),
'approved_at' => now(),
]);
$usedPersonSlots[$person->id][] = $terreinShift->time_slot_id;
}
// ── Factory shift assignments (~100) ──
// Track filled (approved/completed) counts per shift to respect slots_total
$shiftFilledCounts = [];
$existingAssignments = ShiftAssignment::whereIn('shift_id', collect($allShifts)->pluck('id'))
->whereIn('status', [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED])
->get()
->groupBy('shift_id');
foreach ($allShifts as $shift) {
$shiftFilledCounts[$shift->id] = ($existingAssignments[$shift->id] ?? collect())->count();
}
$approvedPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->get();
$openShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'open' && $shift->slots_open_for_claiming > 0);
$statusPool = array_merge(
array_fill(0, 75, ShiftAssignmentStatus::APPROVED),
array_fill(0, 15, ShiftAssignmentStatus::PENDING_APPROVAL),
array_fill(0, 5, ShiftAssignmentStatus::REJECTED),
array_fill(0, 5, ShiftAssignmentStatus::CANCELLED),
);
shuffle($statusPool);
$statusIdx = 0;
foreach ($approvedPersons->shuffle() as $person) {
$existing = $usedPersonSlots[$person->id] ?? [];
$available = $openShifts->filter(fn (Shift $shift) => !in_array($shift->time_slot_id, $existing));
if ($available->isEmpty()) {
continue;
}
$numShifts = min(rand(2, 3), $available->count());
$picked = $available->shuffle()->take($numShifts);
foreach ($picked as $shift) {
$status = $statusPool[$statusIdx % count($statusPool)];
$statusIdx++;
$isApproved = in_array($status, [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED]);
// Skip approved assignments if shift is already at capacity
if ($isApproved && ($shiftFilledCounts[$shift->id] ?? 0) >= $shift->slots_total) {
continue;
}
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => $status,
'auto_approved' => $isApproved && rand(0, 1) === 1,
'assigned_at' => now(),
'approved_at' => $isApproved ? now() : null,
'rejection_reason' => $status === ShiftAssignmentStatus::REJECTED ? 'Geen beschikbare plek' : null,
]);
if ($isApproved) {
$shiftFilledCounts[$shift->id] = ($shiftFilledCounts[$shift->id] ?? 0) + 1;
}
$existing[] = $shift->time_slot_id;
}
$usedPersonSlots[$person->id] = $existing;
}
$assignmentCount = ShiftAssignment::whereIn('shift_id', collect($allShifts)->pluck('id'))->count();
$this->command->info(" {$assignmentCount} shift assignments created");
// Auto-correct shift statuses based on actual fill
foreach ($allShifts as $shift) {
$filled = ShiftAssignment::where('shift_id', $shift->id)
->whereIn('status', [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED])
->count();
$correctStatus = $filled >= $shift->slots_total ? 'full' : 'open';
if ($shift->status !== $correctStatus) {
$shift->update(['status' => $correctStatus]);
}
}
// ── Volunteer availabilities (~70) ──
$volSlotKeys = ['vr_middag', 'vr_avond', 'za_middag', 'za_avond', 'zo_middag', 'zo_avond'];
$volSlots = collect($volSlotKeys)->map(fn (string $k) => $ts[$k]);
// Named availabilities
$namedAvails = [
[$jan, $volSlots->random(4), fn () => rand(2, 5)],
[$lisaB, $volSlots->random(5), fn () => rand(1, 5)],
[$ahmedP, $volSlots, fn () => 5],
[$saraJ, collect([$ts['vr_avond'], $ts['za_avond']]), fn () => rand(1, 5)],
[$fatima, collect([$ts['za_middag'], $ts['za_avond'], $ts['zo_middag']]), fn () => rand(1, 5)],
];
$usedAvails = [];
foreach ($namedAvails as [$person, $slots, $prefFn]) {
foreach ($slots as $slot) {
$combo = "{$person->id}_{$slot->id}";
if (isset($usedAvails[$combo])) {
continue;
}
VolunteerAvailability::create([
'person_id' => $person->id,
'time_slot_id' => $slot->id,
'preference_level' => $prefFn(),
'submitted_at' => now(),
]);
$usedAvails[$combo] = true;
}
}
// Factory availabilities for approved volunteers
$approvedVolunteers = Person::where('event_id', $festival->id)
->where('status', 'approved')
->whereHas('crowdType', fn ($q) => $q->where('system_type', 'VOLUNTEER'))
->get();
foreach ($approvedVolunteers as $person) {
$numSlots = rand(2, 5);
foreach ($volSlots->shuffle()->take($numSlots) as $slot) {
$combo = "{$person->id}_{$slot->id}";
if (isset($usedAvails[$combo])) {
continue;
}
VolunteerAvailability::create([
'person_id' => $person->id,
'time_slot_id' => $slot->id,
'preference_level' => rand(1, 5),
'submitted_at' => now(),
]);
$usedAvails[$combo] = true;
}
}
// ── Crowd Lists (7) ──
$bert = $this->users['bert@feestfabriek.nl'];
$allApprovedVolunteers = Person::where('event_id', $festival->id)->where('status', 'approved')
->whereHas('crowdType', fn ($q) => $q->where('system_type', 'VOLUNTEER'))->get();
$allApprovedCrew = Person::where('event_id', $festival->id)->where('status', 'approved')
->whereHas('crowdType', fn ($q) => $q->where('system_type', 'CREW'))->get();
$allApprovedGuests = Person::where('event_id', $festival->id)->where('status', 'approved')
->whereHas('crowdType', fn ($q) => $q->where('system_type', 'GUEST'))->get();
$this->createCrowdList($festival, 'Vaste Bar Crew', CrowdListType::INTERNAL, $this->crowdTypes['VOLUNTEER'], null, false, null, $allApprovedVolunteers->take(20), $bert);
$this->createCrowdList($festival, 'EHBO Team', CrowdListType::INTERNAL, $this->crowdTypes['VOLUNTEER'], null, false, 10, $allApprovedVolunteers->shuffle()->take(5), $bert);
$this->createCrowdList($festival, 'Terreinploeg Pool', CrowdListType::INTERNAL, $this->crowdTypes['VOLUNTEER'], null, true, 25, $allApprovedVolunteers->shuffle()->take(15), $bert);
$this->createCrowdList($festival, 'SecureEvent Beveiliging', CrowdListType::EXTERNAL, $this->crowdTypes['CREW'], $this->companies['SecureEvent BV'], true, 15, $allApprovedCrew->shuffle()->take(8), $bert);
$this->createCrowdList($festival, 'Podiumtechniek Crew', CrowdListType::EXTERNAL, $this->crowdTypes['CREW'], $this->companies['Podiumtechniek Rijnmond'], false, 6, $allApprovedCrew->shuffle()->take(5), $bert);
$this->createCrowdList($festival, 'Catering Crew', CrowdListType::EXTERNAL, $this->crowdTypes['CREW'], $this->companies['Van Dijk Catering'], false, 8, $allApprovedCrew->shuffle()->take(4), $bert);
$this->createCrowdList($festival, 'VIP Gastenlijst', CrowdListType::INTERNAL, $this->crowdTypes['GUEST'], null, true, 30, $allApprovedGuests->take(12), $bert);
// ── Event person activations (crew on sub-events) ──
$activations = [
[$klaas, [$vrijdag, $zaterdag, $zondag]],
[$dennis, [$zaterdag, $zondag]],
[$eva, [$vrijdag]],
[$maria, [$vrijdag, $zaterdag, $zondag]],
];
foreach ($activations as [$person, $events]) {
foreach ($events as $event) {
DB::table('event_person_activations')->insert([
'id' => (string) Str::ulid(),
'event_id' => $event->id,
'person_id' => $person->id,
]);
}
}
// Activate remaining approved crew on 1-3 random sub-events
$subEventIds = [$vrijdag->id, $zaterdag->id, $zondag->id];
$activatedPersonIds = collect($activations)->pluck('0.id');
$remainingCrew = $allApprovedCrew->reject(fn (Person $p) => $activatedPersonIds->contains($p->id));
foreach ($remainingCrew as $person) {
$numEvents = rand(1, 3);
$selectedEvents = collect($subEventIds)->shuffle()->take($numEvents);
foreach ($selectedEvents as $eventId) {
DB::table('event_person_activations')->insert([
'id' => (string) Str::ulid(),
'event_id' => $eventId,
'person_id' => $person->id,
]);
}
}
// ── User Organisation Tags ──
$tagData = [
['user' => $volunteerUsers['jan@gmail.com'], 'tag' => 'Tapper', 'source' => 'self_reported', 'proficiency' => 'experienced'],
['user' => $volunteerUsers['jan@gmail.com'], 'tag' => 'EHBO', 'source' => 'organiser_assigned', 'proficiency' => 'beginner', 'assigned_by' => $bert->id],
['user' => $volunteerUsers['ahmed.h@gmail.com'], 'tag' => 'EHBO', 'source' => 'self_reported', 'proficiency' => 'expert'],
['user' => $volunteerUsers['ahmed.h@gmail.com'], 'tag' => 'BHV', 'source' => 'organiser_assigned', 'proficiency' => 'expert', 'assigned_by' => $bert->id],
['user' => $volunteerUsers['ahmed.h@gmail.com'], 'tag' => 'Rijbewijs B', 'source' => 'self_reported', 'proficiency' => null],
['user' => $volunteerUsers['tom.visser@gmail.com'], 'tag' => 'Podiumervaring', 'source' => 'self_reported', 'proficiency' => 'experienced'],
['user' => $volunteerUsers['tom.visser@gmail.com'], 'tag' => 'Engels', 'source' => 'self_reported', 'proficiency' => 'experienced'],
['user' => $volunteerUsers['tom.visser@gmail.com'], 'tag' => 'Duits', 'source' => 'self_reported', 'proficiency' => 'beginner'],
['user' => $volunteerUsers['lotte@gmail.com'], 'tag' => 'Rijbewijs B', 'source' => 'self_reported', 'proficiency' => null],
['user' => $volunteerUsers['lotte@gmail.com'], 'tag' => 'Heftruck', 'source' => 'organiser_assigned', 'proficiency' => 'experienced', 'assigned_by' => $bert->id],
];
foreach ($tagData as $td) {
UserOrganisationTag::create([
'user_id' => $td['user']->id,
'organisation_id' => $this->org->id,
'person_tag_id' => $this->personTags[$td['tag']]->id,
'source' => $td['source'],
'assigned_by_user_id' => $td['assigned_by'] ?? null,
'proficiency' => $td['proficiency'],
'assigned_at' => now(),
]);
}
$formSchema = FormBuilderDevSeeder::seedEventSchema($festival);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($festival, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created");
// Sprint 0.5 showcase: public-token-enabled event_registration
// schema + draft + submitted submission that exercises
// FORM-02 / §31.10 TAG_PICKER sync end-to-end.
if (app()->environment('local', 'testing', 'development')) {
FormBuilderDevSeeder::seedEventRegistrationShowcase($this->org, $festival, $this->command);
}
$this->command->info(' Echt Feesten 2026 complete');
});
}
// =========================================================================
// Event 2: Braderie Dorpstown 2026 (flat event, registration_open)
// =========================================================================
private function seedBraderie(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding Braderie Dorpstown 2026...');
$braderie = Event::create([
'organisation_id' => $this->org->id,
'name' => 'Braderie Dorpstown 2026',
'slug' => 'braderie-dorpstown-2026',
'start_date' => '2026-06-14',
'end_date' => '2026-06-14',
'timezone' => 'Europe/Amsterdam',
'status' => 'registration_open',
'event_type' => 'event',
]);
// ── Time Slots (3) ──
$tsOchtend = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag ochtend — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-06-14', 'start_time' => '07:00', 'end_time' => '13:00', 'duration_hours' => 6.00]);
$tsMiddag = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-06-14', 'start_time' => '13:00', 'end_time' => '18:00', 'duration_hours' => 5.00]);
$tsCrew = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag — crew', 'person_type' => 'CREW', 'date' => '2026-06-14', 'start_time' => '06:00', 'end_time' => '20:00', 'duration_hours' => 14.00]);
// ── Sections (2) ──
$verkeersregelaars = FestivalSection::create([
'event_id' => $braderie->id, 'name' => 'Verkeersregelaars', 'type' => 'standard',
'category' => 'Veiligheid', 'icon' => 'tabler-traffic-cone', 'sort_order' => 1,
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
'show_in_registration' => true, 'registration_description' => 'Verkeersregelaar bij de braderie',
]);
$centraleBar = FestivalSection::create([
'event_id' => $braderie->id, 'name' => 'Centrale bar', 'type' => 'standard',
'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2,
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
'show_in_registration' => true, 'registration_description' => 'Tappen en serveren bij de centrale bar',
]);
// ── Shifts (4) ──
Shift::create(['festival_section_id' => $verkeersregelaars->id, 'time_slot_id' => $tsOchtend->id, 'title' => 'Verkeersregelaar', 'slots_total' => 4, 'slots_open_for_claiming' => 4, 'status' => 'open']);
Shift::create(['festival_section_id' => $verkeersregelaars->id, 'time_slot_id' => $tsMiddag->id, 'title' => 'Verkeersregelaar', 'slots_total' => 4, 'slots_open_for_claiming' => 4, 'status' => 'open']);
Shift::create(['festival_section_id' => $centraleBar->id, 'time_slot_id' => $tsOchtend->id, 'title' => 'Tapper', 'slots_total' => 6, 'slots_open_for_claiming' => 6, 'status' => 'open']);
Shift::create(['festival_section_id' => $centraleBar->id, 'time_slot_id' => $tsMiddag->id, 'title' => 'Tapper', 'slots_total' => 6, 'slots_open_for_claiming' => 6, 'status' => 'open']);
// ── Persons (15) ──
$vol = $this->crowdTypes['VOLUNTEER']->id;
Person::factory()->count(10)->approved()->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol]);
Person::factory()->count(3)->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol, 'status' => 'pending']);
Person::factory()->count(2)->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol, 'status' => 'applied']);
$this->linkUsersToApprovedPersons($braderie);
$formSchema = FormBuilderDevSeeder::seedEventSchema($braderie);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created");
$this->command->info(' Braderie Dorpstown 2026 complete');
});
}
// =========================================================================
// Event 3: IJsbaan Winterpark (series, published, 60 persons)
// =========================================================================
private function seedIJsbaan(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding IJsbaan Winterpark...');
// ── Event hierarchy ──
$ijsbaan = Event::create([
'organisation_id' => $this->org->id,
'name' => 'IJsbaan Winterpark',
'slug' => 'ijsbaan-winterpark',
'start_date' => '2026-12-05',
'end_date' => '2027-01-25',
'timezone' => 'Europe/Amsterdam',
'status' => 'published',
'event_type' => 'series',
'event_type_label' => 'Schaatsseizoen',
'sub_event_label' => 'Editie',
]);
$weeksData = [
['name' => 'Week 1 — Opening', 'start' => '2026-12-05', 'end' => '2026-12-07', 'status' => 'published', 'zaterdag' => '2026-12-05', 'zondag' => '2026-12-06'],
['name' => 'Week 2 — Kerst Special', 'start' => '2026-12-19', 'end' => '2026-12-21', 'status' => 'published', 'zaterdag' => '2026-12-19', 'zondag' => '2026-12-20'],
['name' => 'Week 3 — Nieuwjaar', 'start' => '2027-01-02', 'end' => '2027-01-04', 'status' => 'draft', 'zaterdag' => '2027-01-03', 'zondag' => '2027-01-04'],
['name' => 'Week 4 — Afsluiting', 'start' => '2027-01-23', 'end' => '2027-01-25', 'status' => 'draft', 'zaterdag' => '2027-01-24', 'zondag' => '2027-01-25'],
];
$weeks = [];
foreach ($weeksData as $i => $wd) {
$weeks[$i] = Event::create([
'organisation_id' => $this->org->id,
'parent_event_id' => $ijsbaan->id,
'name' => $wd['name'],
'slug' => 'ijsbaan-week-' . ($i + 1),
'start_date' => $wd['start'],
'end_date' => $wd['end'],
'timezone' => 'Europe/Amsterdam',
'status' => $wd['status'],
'event_type' => 'event',
]);
}
// ── Locations (2, on parent) ──
Location::create(['event_id' => $ijsbaan->id, 'name' => 'IJsbaan Kralingse Plas', 'address' => 'Kralingseweg 200, Rotterdam', 'lat' => 51.9300, 'lng' => 4.5100]);
Location::create(['event_id' => $ijsbaan->id, 'name' => 'Verwarmde tent', 'address' => 'Kralingseweg 200A, Rotterdam', 'lat' => 51.9302, 'lng' => 4.5105]);
// ── Sections, time slots, shifts per sub-event ──
$sectionDefs = [
['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'show_in_registration' => true, 'registration_description' => 'Warme dranken en snacks serveren aan schaatsers'],
['name' => 'Schaatsverhuur', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'show_in_registration' => true, 'registration_description' => 'Schaatsen uitgeven en innemen bij de verhuurbalie'],
['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel', 'show_in_registration' => true, 'registration_description' => 'IJsbaan onderhoud en terreinbeheer'],
];
$allShifts = [];
foreach ($weeks as $i => $week) {
$wd = $weeksData[$i];
$isOpen = in_array($wd['status'], ['published', 'registration_open']);
// Sections
$weekSections = [];
foreach ($sectionDefs as $order => $sd) {
$weekSections[] = FestivalSection::create([
'event_id' => $week->id,
'name' => $sd['name'],
'type' => 'standard',
'category' => $sd['category'],
'icon' => $sd['icon'],
'sort_order' => $order + 1,
'responder_self_checkin' => true,
'crew_auto_accepts' => true,
'show_in_registration' => $sd['show_in_registration'] ?? false,
'registration_description' => $sd['registration_description'] ?? null,
]);
}
// Time slots
$zatSlot = TimeSlot::create(['event_id' => $week->id, 'name' => 'Zaterdag', 'person_type' => 'VOLUNTEER', 'date' => $wd['zaterdag'], 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]);
$zonSlot = TimeSlot::create(['event_id' => $week->id, 'name' => 'Zondag', 'person_type' => 'VOLUNTEER', 'date' => $wd['zondag'], 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]);
// Shifts
foreach ($weekSections as $section) {
foreach ([$zatSlot, $zonSlot] as $slot) {
$shift = Shift::create([
'festival_section_id' => $section->id,
'time_slot_id' => $slot->id,
'title' => $section->name,
'slots_total' => 6,
'slots_open_for_claiming' => 6,
'status' => $isOpen ? 'open' : 'draft',
]);
$allShifts[] = $shift;
}
}
}
// ── Persons (60, all factory) ──
$vol = $this->crowdTypes['VOLUNTEER']->id;
$crewType = $this->crowdTypes['CREW']->id;
$guestType = $this->crowdTypes['GUEST']->id;
$approvedVol = Person::factory()->count(35)->approved()->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $vol]);
Person::factory()->count(5)->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $vol, 'status' => 'pending']);
Person::factory()->count(3)->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $vol, 'status' => 'applied']);
$approvedCrew = Person::factory()->count(10)->approved()->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $crewType]);
Person::factory()->count(2)->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $crewType, 'status' => 'pending']);
Person::factory()->count(5)->approved()->create(['event_id' => $ijsbaan->id, 'crowd_type_id' => $guestType]);
$this->linkUsersToApprovedPersons($ijsbaan);
// ── Shift assignments (~80) ──
$openShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'open' && $shift->slots_open_for_claiming > 0);
$draftShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'draft');
$allApproved = $approvedVol->merge($approvedCrew);
// Week 1+2: up to slots_total per open shift
foreach ($openShifts as $shift) {
$count = min(rand(5, 6), $shift->slots_total);
$assigned = $allApproved->shuffle()->take($count);
foreach ($assigned as $person) {
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => true,
'assigned_at' => now(),
'approved_at' => now(),
]);
}
}
// ~10 pending for week 3 draft shifts
$pendingCount = 0;
foreach ($draftShifts->take(4) as $shift) {
$assigned = $allApproved->shuffle()->take(rand(2, 3));
foreach ($assigned as $person) {
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
'assigned_at' => now(),
]);
$pendingCount++;
if ($pendingCount >= 10) {
break 2;
}
}
}
// ── Crowd Lists (2) ──
$bert = $this->users['bert@feestfabriek.nl'];
$this->createCrowdList($ijsbaan, 'IJsbaan Vrijwilligerspool', CrowdListType::INTERNAL, $this->crowdTypes['VOLUNTEER'], null, false, null, $approvedVol, $bert);
$this->createCrowdList($ijsbaan, 'IJsbaan Vaste Crew', CrowdListType::INTERNAL, $this->crowdTypes['CREW'], null, false, null, $approvedCrew, $bert);
$personCount = Person::where('event_id', $ijsbaan->id)->count();
$this->command->info(" {$personCount} persons, " . count($allShifts) . ' shifts created');
$formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created");
});
}
// =========================================================================
// Event 3: Koningsdag Rotterdam 2026 (flat event, closed, 100 persons)
// =========================================================================
private function seedKoningsdag(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding Koningsdag Rotterdam 2026...');
// ── Event ──
$koningsdag = Event::create([
'organisation_id' => $this->org->id,
'name' => 'Koningsdag Rotterdam 2026',
'slug' => 'koningsdag-rotterdam-2026',
'start_date' => '2026-04-27',
'end_date' => '2026-04-27',
'timezone' => 'Europe/Amsterdam',
'status' => 'closed',
'event_type' => 'event',
]);
// ── Locations (3) ──
$locErasmus = Location::create(['event_id' => $koningsdag->id, 'name' => 'Erasmusbrug podium', 'address' => 'Erasmusbrug, Rotterdam', 'lat' => 51.9090, 'lng' => 4.4870]);
$locWillems = Location::create(['event_id' => $koningsdag->id, 'name' => 'Willemsplein', 'address' => 'Willemsplein, Rotterdam', 'lat' => 51.9070, 'lng' => 4.4860]);
$locOudeHaven = Location::create(['event_id' => $koningsdag->id, 'name' => 'Oude Haven', 'address' => 'Oude Haven, Rotterdam', 'lat' => 51.9200, 'lng' => 4.4950]);
// ── Sections (4) ──
$secPodium = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Podium Erasmusbrug', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 1, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Podiummedewerker bij de Erasmusbrug']);
$secBar = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Bar Willemsplein', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tappen en serveren op het Willemsplein']);
$secKids = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Kinderactiviteiten', 'type' => 'standard', 'category' => 'Entertainment', 'icon' => 'tabler-balloon', 'sort_order' => 3, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Begeleid kinderactiviteiten en spelletjes']);
$secBev = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Beveiliging', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 4, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Beveiliging en crowd management']);
$kSections = [$secPodium, $secBar, $secKids, $secBev];
$kLocations = [$locErasmus, $locWillems, $locOudeHaven, $locOudeHaven];
// ── Time Slots (3) ──
$tsOchtend = TimeSlot::create(['event_id' => $koningsdag->id, 'name' => 'Ochtend', 'person_type' => 'VOLUNTEER', 'date' => '2026-04-27', 'start_time' => '09:00', 'end_time' => '13:00', 'duration_hours' => 4.00]);
$tsMiddag = TimeSlot::create(['event_id' => $koningsdag->id, 'name' => 'Middag', 'person_type' => 'VOLUNTEER', 'date' => '2026-04-27', 'start_time' => '13:00', 'end_time' => '18:00', 'duration_hours' => 5.00]);
$tsAvond = TimeSlot::create(['event_id' => $koningsdag->id, 'name' => 'Avond', 'person_type' => 'VOLUNTEER', 'date' => '2026-04-27', 'start_time' => '18:00', 'end_time' => '23:00', 'duration_hours' => 5.00]);
$kSlots = [$tsOchtend, $tsMiddag, $tsAvond];
// ── Shifts (12, all completed) ──
$kShifts = [];
foreach ($kSections as $i => $section) {
foreach ($kSlots as $slot) {
$slotsTotal = $section->name === 'Bar Willemsplein' ? 15 : rand(8, 12);
$kShifts[] = Shift::create([
'festival_section_id' => $section->id,
'time_slot_id' => $slot->id,
'location_id' => $kLocations[$i]->id,
'title' => match ($section->name) {
'Bar Willemsplein' => 'Tapper',
'Podium Erasmusbrug' => 'Podiummedewerker',
'Kinderactiviteiten' => 'Animator',
'Beveiliging' => 'Beveiliger',
},
'slots_total' => $slotsTotal,
'slots_open_for_claiming' => 0,
'status' => 'completed',
]);
}
}
// ── Persons (100, all factory) ──
$vol = $this->crowdTypes['VOLUNTEER']->id;
$crewType = $this->crowdTypes['CREW']->id;
$pressType = $this->crowdTypes['PRESS']->id;
$guestType = $this->crowdTypes['GUEST']->id;
$supplierType = $this->crowdTypes['SUPPLIER']->id;
// 75 volunteers (55 approved, 8 no_show, 5 rejected, 4 approved no-shift, 3 rejected)
$kApprovedVol = Person::factory()->count(59)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $vol]);
$kNoShowVol = Person::factory()->count(8)->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $vol, 'status' => 'no_show']);
Person::factory()->count(8)->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $vol, 'status' => 'rejected']);
// 12 crew (8 with SecureEvent BV)
$kCrew = Person::factory()->count(8)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $crewType, 'company_id' => $this->companies['SecureEvent BV']->id]);
$kCrew = $kCrew->merge(Person::factory()->count(4)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $crewType]));
// 6 press
Person::factory()->count(6)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $pressType]);
// 5 guests (with Rotterdam Festivals)
$kGuests = Person::factory()->count(5)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $guestType, 'company_id' => $this->companies['Rotterdam Festivals']->id]);
// 2 suppliers
Person::factory()->count(2)->approved()->create(['event_id' => $koningsdag->id, 'crowd_type_id' => $supplierType]);
$this->linkUsersToApprovedPersons($koningsdag);
// ── Shift assignments (~150) ──
// 120 completed + 12 cancelled + 8 no-show + 5 rejected + 5 pending
$statusPool = array_merge(
array_fill(0, 120, 'completed'),
array_fill(0, 12, 'cancelled'),
array_fill(0, 5, 'rejected'),
array_fill(0, 5, 'pending'),
);
shuffle($statusPool);
$statusIdx = 0;
// Assign approved volunteers 2-3 shifts each
foreach ($kApprovedVol as $person) {
$numShifts = rand(2, 3);
$picked = collect($kShifts)->shuffle()->take($numShifts);
foreach ($picked as $shift) {
$statusKey = $statusPool[$statusIdx % count($statusPool)] ?? 'completed';
$statusIdx++;
$status = match ($statusKey) {
'completed' => ShiftAssignmentStatus::COMPLETED,
'cancelled' => ShiftAssignmentStatus::CANCELLED,
'rejected' => ShiftAssignmentStatus::REJECTED,
'pending' => ShiftAssignmentStatus::PENDING_APPROVAL,
};
$isCompleted = $status === ShiftAssignmentStatus::COMPLETED;
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => $status,
'auto_approved' => $isCompleted,
'assigned_at' => now(),
'approved_at' => $isCompleted ? now() : null,
'hours_completed' => $isCompleted ? round(rand(35, 80) / 10, 1) : null,
'checked_in_at' => $isCompleted ? now() : null,
'checked_out_at' => $isCompleted ? now()->addHours(rand(3, 8)) : null,
]);
}
}
// No-show persons: approved assignment but no check-in
foreach ($kNoShowVol as $person) {
$shift = collect($kShifts)->random();
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => true,
'assigned_at' => now(),
'approved_at' => now(),
'checked_in_at' => null,
]);
}
// ── Crowd Lists (3) ──
$bert = $this->users['bert@feestfabriek.nl'];
$allKVolunteers = Person::where('event_id', $koningsdag->id)->where('status', 'approved')
->whereHas('crowdType', fn ($q) => $q->where('system_type', 'VOLUNTEER'))->get();
$this->createCrowdList($koningsdag, 'Koningsdag Crew 2026', CrowdListType::INTERNAL, $this->crowdTypes['VOLUNTEER'], null, false, null, $allKVolunteers->take(55), $bert);
$this->createCrowdList($koningsdag, 'Beveiliging Koningsdag', CrowdListType::EXTERNAL, $this->crowdTypes['CREW'], $this->companies['SecureEvent BV'], false, null, $kCrew->take(8), $bert);
$this->createCrowdList($koningsdag, 'Gemeente Gasten', CrowdListType::EXTERNAL, $this->crowdTypes['GUEST'], $this->companies['Rotterdam Festivals'], false, null, $kGuests, $bert);
$personCount = Person::where('event_id', $koningsdag->id)->count();
$assignCount = ShiftAssignment::whereIn('shift_id', collect($kShifts)->pluck('id'))->count();
$this->command->info(" {$personCount} persons, 12 shifts, {$assignCount} assignments");
$formSchema = FormBuilderDevSeeder::seedEventSchema($koningsdag);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created");
});
}
// =========================================================================
// Event 4: Nacht van de Kaap 2026 (flat event, draft, empty)
// =========================================================================
private function seedNachtVanDeKaap(): void
{
DB::transaction(function (): void {
$this->command->info('Seeding Nacht van de Kaap 2026...');
$event = Event::create([
'organisation_id' => $this->org->id,
'name' => 'Nacht van de Kaap 2026',
'slug' => 'nacht-van-de-kaap-2026',
'start_date' => '2026-09-12',
'end_date' => '2026-09-12',
'timezone' => 'Europe/Amsterdam',
'status' => 'draft',
'event_type' => 'event',
]);
Location::create(['event_id' => $event->id, 'name' => 'Katendrecht', 'address' => 'Deliplein, Rotterdam', 'lat' => 51.8950, 'lng' => 4.4850]);
FestivalSection::create(['event_id' => $event->id, 'name' => 'Kaapse Bar', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 1, 'responder_self_checkin' => true]);
FestivalSection::create(['event_id' => $event->id, 'name' => 'Podium Deliplein', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 2, 'responder_self_checkin' => true]);
TimeSlot::create(['event_id' => $event->id, 'name' => 'Nacht', 'person_type' => 'VOLUNTEER', 'date' => '2026-09-12', 'start_time' => '20:00', 'end_time' => '04:00', 'duration_hours' => 8.00]);
FormBuilderDevSeeder::seedEventSchema($event);
$this->command->info(' Empty draft event created (with form schema, 0 submissions)');
});
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Create User accounts for approved/no_show persons that lack one.
* Mirrors the production approval flow (PersonController::approve).
*/
private function linkUsersToApprovedPersons(Event $event): int
{
$linked = 0;
Person::withoutGlobalScopes()
->where('event_id', $event->id)
->whereIn('status', ['approved', 'no_show'])
->whereNull('user_id')
->each(function (Person $person) use (&$linked): void {
$user = User::firstOrCreate(
['email' => strtolower($person->email)],
[
'first_name' => $person->first_name,
'last_name' => $person->last_name,
'password' => Hash::make('password'),
]
);
$person->user_id = $user->id;
$person->save();
$linked++;
});
return $linked;
}
private function createCrowdList(
Event $event,
string $name,
CrowdListType $type,
CrowdType $crowdType,
?Company $company,
bool $autoApprove,
?int $maxPersons,
Collection $persons,
User $addedBy,
): CrowdList {
$list = CrowdList::create([
'event_id' => $event->id,
'crowd_type_id' => $crowdType->id,
'name' => $name,
'type' => $type,
'recipient_company_id' => $company?->id,
'auto_approve' => $autoApprove,
'max_persons' => $maxPersons,
]);
$pivotData = $persons->mapWithKeys(fn (Person $p) => [
$p->id => ['added_at' => now(), 'added_by_user_id' => $addedBy->id],
])->all();
$list->persons()->attach($pivotData);
return $list;
}
}