*/ private const GENRE_SPECS = [ ['name' => 'Hardstyle', 'color' => '#e85d75'], ['name' => 'Techno', 'color' => '#1f6feb'], ['name' => 'Indie', 'color' => '#7c3aed'], ['name' => 'Live band', 'color' => '#0ea5e9'], ['name' => 'House', 'color' => '#22c55e'], ['name' => 'Hip-hop', 'color' => '#f59e0b'], ['name' => 'Drum & Bass', 'color' => '#dc2626'], ['name' => 'Akoestisch', 'color' => '#6366f1'], ]; // ========================================================================= // Public entry points // ========================================================================= /** * Seed an organisation-level roster: 8 genres + N artists. * * @return array{genres: Collection, artists: Collection} */ public static function seedOrganisationPool(Organisation $org, int $artistCount = 125): array { $genres = self::createGenres($org); $artists = self::createArtistPool($org, $genres, $artistCount); return ['genres' => $genres, 'artists' => $artists]; } /** * Seed Echt Feesten festival fixture: 5 stages, ~30 scheduled * engagements over 3 days, ~10 unscheduled engagements. Preserves * the prototype audit fixture's special test cases (B2B pair on * Mainstage Saturday, multi-perf engagement, parked performance). * * @param array $subEvents Sub-events keyed 0..n in date order * @param array{genres: Collection, artists: Collection} $pool * @return array{stages: int, engagements: int, performances: int, unscheduled: int} */ public static function seedForFestival(Organisation $org, Event $festival, array $subEvents, array $pool): array { if (count($subEvents) < 3) { return ['stages' => 0, 'engagements' => 0, 'performances' => 0, 'unscheduled' => 0]; } $stages = self::createStages($festival, [ ['name' => 'Mainstage', 'color' => '#e85d75', 'capacity' => 4500, 'sort_order' => 1], ['name' => 'Havana', 'color' => '#0ea5e9', 'capacity' => 1200, 'sort_order' => 2], ['name' => 'Stairway', 'color' => '#7c3aed', 'capacity' => 800, 'sort_order' => 3], ['name' => 'Socialite', 'color' => '#22c55e', 'capacity' => 600, 'sort_order' => 4], ['name' => 'Tent op het Plein', 'color' => '#f59e0b', 'capacity' => 400, 'sort_order' => 5], ], $subEvents); // Reserve the first 50 artists from the org pool for festival // engagements. (Other events draw from later slices to keep // some artists reserved for "unscheduled" / pure-pool variety.) $bucket = $pool['artists']->slice(0, 50)->values(); $bucketIdx = 0; $engagements = 0; $performances = 0; $unscheduled = 0; // ── Scheduled engagements: 30 across 3 days ── // Status mix loosely follows RFC §5.3: lots of contracted/confirmed, // some optioned/requested, a couple of drafts and one cancelled. $statusMix = [ ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option, ArtistEngagementStatus::Option, ArtistEngagementStatus::Requested, ]; $statusIdx = 0; $hourSlots = [16, 17, 18, 19, 20, 21, 22, 23]; // Per RFC §D10/§D17 + SCHEMA.md:1285+1329: // engagement.event_id = festival (the parent — one per artist per festival) // performance.event_id = sub-event ("show host" day) // Same artist on multiple sub-events shares ONE engagement (D17: // "Friday + Saturday under one combined deal = 1 engagement, 2 performances"). foreach ($subEvents as $dayIdx => $subEvent) { // Each sub-event gets 10 scheduled performances across the 5 stages for ($i = 0; $i < 10; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; $status = $statusMix[$statusIdx++ % count($statusMix)]; // unique(artist_id, event_id) at the festival — reuse the engagement // if the artist was already booked for this festival, adding another // performance under it (D17 multi-perf path). $engagement = ArtistEngagement::query() ->where('artist_id', $artist->id) ->where('event_id', $festival->id) ->first(); if ($engagement === null) { $engagement = self::createEngagement($artist, $festival, $status); $engagements++; } $hour = $hourSlots[$i % count($hourSlots)]; $minute = ($i % 4) * 15; $stage = $stages->values()[$i % $stages->count()]; self::createPerformance($engagement, $subEvent, $stage, $hour, $minute, 60); $performances++; // ~25% of contracted/confirmed engagements get a 2nd perf (D17 multi-perf) if ($i % 4 === 0 && in_array($status, [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed], true)) { self::createPerformance($engagement, $subEvent, $stage, $hour + 4, $minute, 45); $performances++; } } } // ── Unscheduled engagements: 12 artists linked but no performance ── $unscheduledStatusMix = [ ArtistEngagementStatus::Draft, ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option, ArtistEngagementStatus::Option, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Offered, ]; $u = 0; for ($i = 0; $i < 12; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; // Skip if this artist already has an engagement at the festival // (e.g. via the scheduled loop above). $existing = ArtistEngagement::query() ->where('artist_id', $artist->id) ->where('event_id', $festival->id) ->exists(); if ($existing) { continue; } self::createEngagement($artist, $festival, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]); $engagements++; $unscheduled++; } // ── One parked performance (= scheduled but stage_id = null) ── // Exercises the Session 4 "wachtrij" UI. Engagement at festival level; // performance.event_id is the sub-event the parked slot belongs to. $parkedArtist = $bucket[$bucketIdx++ % $bucket->count()]; $parkedSub = $subEvents[0]; if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $festival->id)->exists()) { $parkedEngagement = self::createEngagement($parkedArtist, $festival, ArtistEngagementStatus::Confirmed); Performance::create([ 'engagement_id' => $parkedEngagement->id, 'event_id' => $parkedSub->id, 'stage_id' => null, 'lane' => 0, 'start_at' => CarbonImmutable::parse($parkedSub->start_date)->setTime(0, 0), 'end_at' => CarbonImmutable::parse($parkedSub->start_date)->setTime(1, 0), 'version' => 0, ]); $engagements++; $performances++; } // ── B2B pair on Mainstage Saturday with 3-min offset (B2B detector seed) ── // Two artists with consecutive performances on the same stage within 3 minutes. $zaterdag = $subEvents[1]; $b2bA = $bucket[$bucketIdx++ % $bucket->count()]; $b2bB = $bucket[$bucketIdx++ % $bucket->count()]; $mainstage = $stages['Mainstage'] ?? $stages->first(); foreach ([['artist' => $b2bA, 'minute' => 30], ['artist' => $b2bB, 'minute' => 33]] as $pair) { if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $festival->id)->exists()) { continue; } $eng = self::createEngagement($pair['artist'], $festival, ArtistEngagementStatus::Contracted); self::createPerformance($eng, $zaterdag, $mainstage, 23, $pair['minute'], 60); $engagements++; $performances++; } return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; } /** * Seed a flat single-day event (e.g. Braderie, Koningsdag, Nacht). * * @param array{genres: Collection, artists: Collection} $pool * @param array{ * stages?: list, * scheduled?: int, * unscheduled?: int, * pool_offset?: int, * start_hour?: int, * end_hour?: int, * force_status?: ArtistEngagementStatus * } $config * @return array{stages: int, engagements: int, performances: int, unscheduled: int} */ public static function seedForFlatEvent(Organisation $org, Event $event, array $pool, array $config = []): array { $stageSpecs = $config['stages'] ?? [ ['name' => 'Hoofdpodium', 'color' => '#e85d75', 'capacity' => 1500, 'sort_order' => 1], ['name' => 'Tweede Podium', 'color' => '#0ea5e9', 'capacity' => 600, 'sort_order' => 2], ]; $stages = self::createStages($event, $stageSpecs, [$event]); $scheduledCount = $config['scheduled'] ?? 10; $unscheduledCount = $config['unscheduled'] ?? 5; $offset = $config['pool_offset'] ?? 50; $startHour = $config['start_hour'] ?? 14; $endHour = $config['end_hour'] ?? 23; $forceStatus = $config['force_status'] ?? null; $bucket = $pool['artists']->slice($offset, $scheduledCount + $unscheduledCount + 4)->values(); $bucketIdx = 0; $engagements = 0; $performances = 0; $unscheduled = 0; $statusMix = $forceStatus !== null ? [$forceStatus] : [ ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option, ArtistEngagementStatus::Requested, ]; $statusIdx = 0; $totalHours = max(1, $endHour - $startHour); for ($i = 0; $i < $scheduledCount; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $event->id)->exists()) { continue; } $status = $statusMix[$statusIdx++ % count($statusMix)]; $engagement = self::createEngagement($artist, $event, $status); $engagements++; $stage = $stages->values()[$i % $stages->count()]; $hour = $startHour + ($i % $totalHours); $minute = ($i % 4) * 15; self::createPerformance($engagement, $event, $stage, $hour, $minute, 60); $performances++; } $unscheduledStatusMix = $forceStatus !== null ? [$forceStatus] : [ ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option, ArtistEngagementStatus::Confirmed, ]; for ($i = 0; $i < $unscheduledCount; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $event->id)->exists()) { continue; } self::createEngagement($artist, $event, $unscheduledStatusMix[$i % count($unscheduledStatusMix)]); $engagements++; $unscheduled++; } return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; } /** * Seed a recurring series (e.g. IJsbaan): stages live on the parent * event, and each weekly sub-event gets its own scheduled + * unscheduled engagements. * * @param array $subEvents * @param array{genres: Collection, artists: Collection} $pool * @param array{ * stages?: list, * per_week_scheduled?: int, * per_week_unscheduled?: int, * pool_offset?: int * } $config * @return array{stages: int, engagements: int, performances: int, unscheduled: int} */ public static function seedForSeries(Organisation $org, Event $parent, array $subEvents, array $pool, array $config = []): array { $stageSpecs = $config['stages'] ?? [ ['name' => 'Schaatspaviljoen', 'color' => '#0ea5e9', 'capacity' => 800, 'sort_order' => 1], ['name' => 'Verwarmde Tent', 'color' => '#22c55e', 'capacity' => 300, 'sort_order' => 2], ]; $stages = self::createStages($parent, $stageSpecs, $subEvents); $perWeekScheduled = $config['per_week_scheduled'] ?? 3; $perWeekUnscheduled = $config['per_week_unscheduled'] ?? 2; $offset = $config['pool_offset'] ?? 80; $bucket = $pool['artists']->slice($offset, ($perWeekScheduled + $perWeekUnscheduled) * count($subEvents) + 4)->values(); $bucketIdx = 0; $engagements = 0; $performances = 0; $unscheduled = 0; // Same model as the festival path: engagement at the parent series // event, performance at the per-week sub-event. A resident artist // playing multiple weeks gets ONE engagement with N performances. foreach ($subEvents as $weekIdx => $week) { for ($i = 0; $i < $perWeekScheduled; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; $status = [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option][$i % 3]; $engagement = ArtistEngagement::query() ->where('artist_id', $artist->id) ->where('event_id', $parent->id) ->first(); if ($engagement === null) { $engagement = self::createEngagement($artist, $parent, $status); $engagements++; } $stage = $stages->values()[$i % $stages->count()]; self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60); $performances++; } for ($i = 0; $i < $perWeekUnscheduled; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $parent->id)->exists()) { continue; } $status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3]; self::createEngagement($artist, $parent, $status); $engagements++; $unscheduled++; } } return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; } // ========================================================================= // Internal helpers // ========================================================================= /** @return Collection */ private static function createGenres(Organisation $org): Collection { $genres = collect(); foreach (self::GENRE_SPECS as $i => $spec) { $genres[$spec['name']] = Genre::create([ 'organisation_id' => $org->id, 'name' => $spec['name'], 'color' => $spec['color'], 'sort_order' => $i + 1, 'is_active' => true, ]); } return $genres; } /** * @param Collection $genres * @return Collection */ private static function createArtistPool(Organisation $org, Collection $genres, int $count): Collection { $names = self::generateArtistNames($count); $countries = ['NL', 'NL', 'NL', 'NL', 'NL', 'BE', 'BE', 'DE', 'UK', 'FR']; $genreList = $genres->values(); $artists = collect(); foreach ($names as $idx => $name) { $genre = $genreList[$idx % $genreList->count()]; $artist = Artist::create([ 'organisation_id' => $org->id, 'name' => $name, 'default_genre_id' => $genre->id, 'default_draw' => fake()->numberBetween(50, 5000), 'star_rating' => fake()->numberBetween(1, 5), 'home_base_country' => $countries[$idx % count($countries)], ]); $artists->push($artist); // ~30% get a tour-manager contact for downstream advance/portal flows if ($idx % 3 === 0) { ArtistContact::create([ 'artist_id' => $artist->id, 'name' => 'Tour Manager '.$artist->name, 'email' => 'tm-'.$artist->slug.'@example.test', 'phone' => '+316123'.str_pad((string) ($idx + 1), 5, '0', STR_PAD_LEFT), 'role' => 'tour_manager', 'is_primary' => true, 'receives_briefing' => true, 'receives_infosheet' => true, ]); } } return $artists; } /** * Generate $count unique artist names by combining curated parts. * The first ~40 entries are hand-picked "real-feeling" names so the * top of the list looks human; the remainder are generated. * * @return list */ private static function generateArtistNames(int $count): array { $curated = [ 'Donker & Licht', 'Voltage Collective', 'Roos & de Wolf', 'Rotterdam Brass', 'Nachtwacht DJs', 'De Lichtbrigade', 'Dijkdoorbraak', 'Kaapse Gasten', 'Polderpop', 'De Stadsklokken', 'Noorderzon Project', 'Bij de Buren', 'Echo van de Maas', 'Kortsluiting', 'Bonte Hond', 'Rauwe Diamant', 'Maan & Sterren', 'Zondagmiddag Sessies', 'Het Geluidsmuseum', 'Storm op Zee', 'De Jonge Honden', 'Volksfeest Brass Band', 'Tussen de Wolken', 'Plat Vlaams', 'Stille Helden', 'De Veerboot', 'Holland Heat', 'Springstof', 'Café Onder de Brug', 'Mannen van Staal', 'Dijk Disco', 'Studio West', 'Het Kleine Orkest', 'Hard tegen Hart', 'Nachtsessie Live', 'Boterham met Tien', 'De Klompendansers', 'Kraakheldere Stemmen', 'Vrijdagavondblues', 'Broeders Beeld', ]; $cores = [ 'Voltage', 'Echo', 'Lumen', 'Donker', 'Licht', 'Storm', 'Zon', 'Maan', 'Stadse', 'Polder', 'Noord', 'Zuid', 'Oost', 'West', 'Maas', 'IJssel', 'Rotterdam', 'Amsterdam', 'Utrecht', 'Eindhoven', 'Brabant', 'Limburg', 'Kraak', 'Veer', 'Brug', 'Plein', 'Markt', 'Park', 'Tuin', 'Bos', 'Ster', 'Wolk', 'Regen', 'Wind', 'Vuur', 'IJs', 'Sneeuw', 'Mist', ]; $suffixes = [ 'Collective', 'Project', 'Sound', 'Live', 'Crew', 'Brothers', 'Sisters', 'Sessions', 'Orkest', 'Band', 'Trio', 'Quartet', 'DJs', 'System', 'Allstars', 'Republic', 'Union', 'Express', 'Riders', 'Beats', ]; $names = $curated; $taken = array_flip($names); $i = 0; while (count($names) < $count) { $core = $cores[$i % count($cores)]; $suffix = $suffixes[(int) ($i / count($cores)) % count($suffixes)]; $candidate = $core.' '.$suffix; if (! isset($taken[$candidate])) { $names[] = $candidate; $taken[$candidate] = true; } $i++; // Safety: avoid infinite loop if combinations exhaust if ($i > count($cores) * count($suffixes) * 2) { $names[] = $core.' '.$suffix.' '.Str::upper(Str::random(3)); } } return array_slice($names, 0, $count); } /** * Create stages on $event and stage_days entries linking each stage * to every $performanceDay (the parent event itself for flat events, * each sub-event for festivals/series). * * @param list $specs * @param array $performanceDays * @return Collection */ private static function createStages(Event $event, array $specs, array $performanceDays): Collection { $stages = collect(); foreach ($specs as $spec) { $stage = Stage::create([ 'event_id' => $event->id, 'name' => $spec['name'], 'color' => $spec['color'], 'capacity' => $spec['capacity'], 'sort_order' => $spec['sort_order'], ]); $stages[$spec['name']] = $stage; foreach ($performanceDays as $day) { StageDay::create([ 'stage_id' => $stage->id, 'event_id' => $day->id, ]); } } return $stages; } private static function createEngagement(Artist $artist, Event $event, ArtistEngagementStatus $status): ArtistEngagement { $attrs = [ 'artist_id' => $artist->id, 'event_id' => $event->id, 'booking_status' => $status, 'fee_currency' => 'EUR', 'buma_applicable' => true, 'buma_percentage' => 7.00, 'buma_handled_by' => 'organisation', 'vat_applicable' => true, 'vat_percentage' => 21.00, 'payment_status' => 'none', 'crew_count' => fake()->numberBetween(1, 4), 'guests_count' => fake()->numberBetween(0, 6), 'advancing_completed_count' => 0, 'advancing_total_count' => 0, ]; if ($status === ArtistEngagementStatus::Option) { $attrs['option_expires_at'] = CarbonImmutable::now()->addDays(fake()->numberBetween(7, 30)); } if ($status === ArtistEngagementStatus::Requested) { $attrs['requested_at'] = CarbonImmutable::now()->subDays(fake()->numberBetween(1, 14)); } if ($status === ArtistEngagementStatus::Contracted) { $attrs['fee_amount'] = fake()->randomFloat(2, 750, 18000); } if ($status === ArtistEngagementStatus::Confirmed) { $attrs['fee_amount'] = fake()->randomFloat(2, 500, 12000); } return ArtistEngagement::create($attrs); } private static function createPerformance( ArtistEngagement $engagement, Event $day, Stage $stage, int $hour, int $minute, int $minutes, ): Performance { $dayOffset = intdiv($hour, 24); $start = CarbonImmutable::parse($day->start_date) ->addDays($dayOffset) ->setTime($hour % 24, $minute); return Performance::create([ 'engagement_id' => $engagement->id, 'event_id' => $day->id, 'stage_id' => $stage->id, 'lane' => 0, 'start_at' => $start, 'end_at' => $start->addMinutes($minutes), 'version' => 0, ]); } }