diff --git a/api/database/seeders/ArtistTimetableDevSeeder.php b/api/database/seeders/ArtistTimetableDevSeeder.php index 0529a3a9..0f167fc7 100644 --- a/api/database/seeders/ArtistTimetableDevSeeder.php +++ b/api/database/seeders/ArtistTimetableDevSeeder.php @@ -15,195 +15,559 @@ use App\Models\Performance; use App\Models\Stage; use App\Models\StageDay; use Carbon\CarbonImmutable; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; /** - * Seeds the Artist & Timetable fixture for an existing festival with - * three sub-events (e.g. Echt Feesten 2026 → Vrijdag/Zaterdag/Zondag). + * Seeds the org-wide artist roster + per-event timetable fixtures. * - * Reproduces the prototype audit fixture as closely as possible: - * - 4 stages on the festival - * - 4 stages × 3 sub-events = 12 stage_days rows - * - 4 genres, 6 master artists with default genre + draw - * - 12 engagements with status mix (Draft 1, Requested 2, Option 3, - * Confirmed 2, Contracted 3, Cancelled 1). Two artists each have - * two engagements (one per day) to exercise the multi-engagement - * case (D17). - * - 13 performances; one parked (stage_id=null); a B2B pair within - * 3 minutes on the same stage/lane to seed Session 4 frontend dev. - * - One ArtistContact per artist (tour-manager role). + * Three entry points: + * - seedOrganisationPool() — 8 genres + 125 artists at the organisation level + * - seedForFestival() — multi-day festival with sub-events (Echt Feesten) + * - seedForFlatEvent() — single-day event (Braderie, Koningsdag, Nacht) + * - seedForSeries() — recurring series with weekly sub-events (IJsbaan) * - * advance_sections seeding lands in Session 3 with the form-builder - * integration. + * Every event seeded gets: + * - 2–5 stages (with stage_days for active days) + * - "scheduled" engagements: artists with one or more performances on a stage + * - "unscheduled" engagements: artists linked to the event but without + * any performance row yet (= booked but not slotted in the timetable) */ final class ArtistTimetableDevSeeder { - /** @param array $subEvents Sub-events keyed 0..n in date order */ - public static function seedForFestival(Organisation $org, Event $festival, array $subEvents): void + /** + * Curated genre palette used across the org pool. + * + * @var list + */ + 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; + return ['stages' => 0, 'engagements' => 0, 'performances' => 0, 'unscheduled' => 0]; } - // Genres - $genres = collect([ - ['name' => 'Hardstyle', 'color' => '#e85d75'], - ['name' => 'Techno', 'color' => '#1f6feb'], - ['name' => 'Indie', 'color' => '#7c3aed'], - ['name' => 'Live band', 'color' => '#0ea5e9'], - ])->mapWithKeys(fn (array $g) => [ - $g['name'] => Genre::create([ - 'organisation_id' => $org->id, - 'name' => $g['name'], - 'color' => $g['color'], - 'sort_order' => 0, - 'is_active' => true, - ]), - ]); - - // Stages on the festival - $stages = collect([ + $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], - ])->mapWithKeys(fn (array $s) => [ - $s['name'] => Stage::create(['event_id' => $festival->id, ...$s]), - ]); + ['name' => 'Tent op het Plein', 'color' => '#f59e0b', 'capacity' => 400, 'sort_order' => 5], + ], $subEvents); - // stage_days — every stage active every sub-event day - foreach ($stages as $stage) { - foreach ($subEvents as $subEvent) { - StageDay::create([ - 'stage_id' => $stage->id, - 'event_id' => $subEvent->id, + // 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]; + + foreach ($subEvents as $dayIdx => $subEvent) { + // Each sub-event gets 10 scheduled engagements 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): only one engagement per + // (artist, sub-event). Since we wrap the pool, skip if + // we'd violate the constraint. + $existing = ArtistEngagement::query() + ->where('artist_id', $artist->id) + ->where('event_id', $subEvent->id) + ->exists(); + if ($existing) { + continue; + } + + $engagement = self::createEngagement($artist, $subEvent, $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()]; + $subEvent = $subEvents[$i % count($subEvents)]; + + $existing = ArtistEngagement::query() + ->where('artist_id', $artist->id) + ->where('event_id', $subEvent->id) + ->exists(); + if ($existing) { + continue; + } + + self::createEngagement($artist, $subEvent, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]); + $engagements++; + $unscheduled++; + } + + // ── One parked performance (= scheduled but stage_id = null) ── + // Exercises the Session 4 "wachtrij" UI. + $parkedArtist = $bucket[$bucketIdx++ % $bucket->count()]; + $parkedSub = $subEvents[0]; + if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $parkedSub->id)->exists()) { + $parkedEngagement = self::createEngagement($parkedArtist, $parkedSub, 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 overlap on the same stage/lane 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', $zaterdag->id)->exists()) { + continue; + } + $eng = self::createEngagement($pair['artist'], $zaterdag, 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; + + foreach ($subEvents as $weekIdx => $week) { + for ($i = 0; $i < $perWeekScheduled; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $week->id)->exists()) { + continue; + } + + $status = [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option][$i % 3]; + $engagement = self::createEngagement($artist, $week, $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', $week->id)->exists()) { + continue; + } + $status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3]; + self::createEngagement($artist, $week, $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, ]); } } - // Master artists - $artistsData = [ - ['name' => 'Donker & Licht', 'genre' => 'Hardstyle', 'draw' => 3500, 'star' => 5], - ['name' => 'Voltage Collective', 'genre' => 'Techno', 'draw' => 1800, 'star' => 4], - ['name' => 'Roos & de Wolf', 'genre' => 'Indie', 'draw' => 700, 'star' => 3], - ['name' => 'Rotterdam Brass', 'genre' => 'Live band', 'draw' => 900, 'star' => 4], - ['name' => 'Nachtwacht DJs', 'genre' => 'Techno', 'draw' => 1100, 'star' => 3], - ['name' => 'De Lichtbrigade', 'genre' => 'Live band', 'draw' => 500, 'star' => 3], + 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', ]; - $artists = []; - foreach ($artistsData as $data) { - /** @var Artist $artist */ - $artist = Artist::create([ - 'organisation_id' => $org->id, - 'name' => $data['name'], - 'default_genre_id' => $genres[$data['genre']]->id, - 'default_draw' => $data['draw'], - 'star_rating' => $data['star'], - 'home_base_country' => 'NL', - ]); - $artists[$data['name']] = $artist; - - ArtistContact::create([ - 'artist_id' => $artist->id, - 'name' => 'Tour Manager '.$artist->name, - 'email' => 'tm-'.$artist->slug.'@example.test', - 'phone' => '+31612340000', - 'role' => 'tour_manager', - 'is_primary' => true, - 'receives_briefing' => true, - 'receives_infosheet' => true, - ]); - } - - // Engagements (12) — status mix per RFC §5.3 Session 1 prompt. - // Two artists get two engagements (different days) to exercise D17. - $statusPlan = [ - ['artist' => 'Donker & Licht', 'sub' => 0, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Donker & Licht', 'sub' => 1, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Voltage Collective', 'sub' => 0, 'status' => ArtistEngagementStatus::Confirmed], - ['artist' => 'Voltage Collective', 'sub' => 1, 'status' => ArtistEngagementStatus::Option], - ['artist' => 'Roos & de Wolf', 'sub' => 1, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Rotterdam Brass', 'sub' => 0, 'status' => ArtistEngagementStatus::Confirmed], - ['artist' => 'Rotterdam Brass', 'sub' => 2, 'status' => ArtistEngagementStatus::Requested], - ['artist' => 'Nachtwacht DJs', 'sub' => 1, 'status' => ArtistEngagementStatus::Option], - ['artist' => 'Nachtwacht DJs', 'sub' => 2, 'status' => ArtistEngagementStatus::Cancelled], - ['artist' => 'De Lichtbrigade', 'sub' => 0, 'status' => ArtistEngagementStatus::Draft], - ['artist' => 'De Lichtbrigade', 'sub' => 1, 'status' => ArtistEngagementStatus::Requested], - ['artist' => 'De Lichtbrigade', 'sub' => 2, 'status' => ArtistEngagementStatus::Option], + $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', ]; - $engagements = []; - foreach ($statusPlan as $idx => $plan) { - /** @var Event $subEvent */ - $subEvent = $subEvents[$plan['sub']]; - $artist = $artists[$plan['artist']]; - - $attrs = [ - 'artist_id' => $artist->id, - 'event_id' => $subEvent->id, - 'booking_status' => $plan['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' => 2, - 'guests_count' => 4, - 'advancing_completed_count' => 0, - 'advancing_total_count' => 0, - ]; - - if ($plan['status'] === ArtistEngagementStatus::Option) { - $attrs['option_expires_at'] = CarbonImmutable::now()->addDays(14); + $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; } - if ($plan['status'] === ArtistEngagementStatus::Requested) { - $attrs['requested_at'] = CarbonImmutable::now()->subDays(3); + $i++; + // Safety: avoid infinite loop if combinations exhaust + if ($i > count($cores) * count($suffixes) * 2) { + $names[] = $core.' '.$suffix.' '.Str::upper(Str::random(3)); } - if ($plan['status'] === ArtistEngagementStatus::Contracted) { - $attrs['fee_amount'] = 7500.00; - } - - $engagements[$idx] = ArtistEngagement::create($attrs); } - // Performances (13) — most engagements get 1 perf, a couple - // get 2 (D17). One parked. One B2B pair on Mainstage Saturday. - $perfPlan = [ - // [engagementIdx, stageName|null, subIdx, hour, minute, duration] - [0, 'Mainstage', 0, 22, 0, 75], // Donker & Licht — Vrijdag mainstage - [1, 'Mainstage', 1, 23, 30, 60], // Donker & Licht — Zaterdag mainstage (B2B partner-A) - [1, 'Mainstage', 1, 21, 0, 60], // …extra perf same engagement (D17 multi-perf) - [2, 'Havana', 0, 21, 0, 60], - [3, 'Havana', 1, 22, 30, 60], - [4, 'Stairway', 1, 21, 0, 75], - [5, 'Socialite', 0, 19, 0, 60], - [6, 'Socialite', 2, 17, 0, 45], - [7, 'Mainstage', 1, 23, 33, 60], // B2B partner-B (3-min offset → seeds B2B detector) - [9, null, 0, 0, 0, 60], // De Lichtbrigade Vrijdag = parked / wachtrij - [10, 'Stairway', 1, 19, 0, 60], - [11, 'Havana', 2, 20, 30, 60], - [4, 'Stairway', 1, 22, 30, 60], // multi-perf on same engagement (D17) + 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, ]; - foreach ($perfPlan as $row) { - [$engagementIdx, $stageName, $subIdx, $hour, $minute, $minutes] = $row; - $engagement = $engagements[$engagementIdx]; - /** @var Event $subEvent */ - $subEvent = $subEvents[$subIdx]; - - $start = CarbonImmutable::parse($subEvent->start_date)->setTime($hour, $minute); - - Performance::create([ - 'engagement_id' => $engagement->id, - 'event_id' => $subEvent->id, - 'stage_id' => $stageName === null ? null : $stages[$stageName]->id, - 'lane' => 0, - 'start_at' => $start, - 'end_at' => $start->addMinutes($minutes), - 'version' => 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, + ]); } } diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index f3e4f331..df4bde1b 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -43,6 +43,9 @@ class DevSeeder extends Seeder /** @var array */ private array $personTags = []; + /** @var array{genres: \Illuminate\Support\Collection, artists: \Illuminate\Support\Collection}|null */ + private ?array $artistPool = null; + public function run(): void { $this->call(RoleSeeder::class); @@ -163,7 +166,14 @@ class DevSeeder extends Seeder $templatesCreated = FormBuilderDevSeeder::seedSystemTemplates($this->org); - $this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates created"); + // ── Org-wide artist pool (8 genres + 125 artists) ── + // Used as the shared roster: every event draws scheduled + + // unscheduled engagements from this pool. + $this->artistPool = ArtistTimetableDevSeeder::seedOrganisationPool($this->org, 125); + $artistCount = $this->artistPool['artists']->count(); + $genreCount = $this->artistPool['genres']->count(); + + $this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates, {$genreCount} genres, {$artistCount} artists created"); }); } @@ -956,14 +966,16 @@ class DevSeeder extends Seeder FormBuilderDevSeeder::seedEventRegistrationShowcase($this->org, $festival, $this->command); } - // RFC-TIMETABLE v0.2 — artist + timetable fixture for the - // festival (4 stages, 6 artists, 12 engagements, 13 perfs). - ArtistTimetableDevSeeder::seedForFestival( + // RFC-TIMETABLE v0.2 — festival timetable: 5 stages, ~30 scheduled + // performances over 3 days + ~12 unscheduled engagements (linked + // but not yet slotted on a stage/timeslot). Draws from the org pool. + $tt = ArtistTimetableDevSeeder::seedForFestival( $this->org, $festival, [$vrijdag, $zaterdag, $zondag], + $this->artistPool, ); - $this->command->info(' Artist timetable: 4 stages, 12 stage_days, 6 artists, 12 engagements, 13 performances'); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); $this->command->info(' Echt Feesten 2026 complete'); }); @@ -1031,6 +1043,20 @@ class DevSeeder extends Seeder $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + // Stages + timetable (flat event, 1 day, lighter program) + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $braderie, $this->artistPool, [ + 'stages' => [ + ['name' => 'Marktplein Podium', 'color' => '#e85d75', 'capacity' => 800, 'sort_order' => 1], + ['name' => 'Akoestiekhoek', 'color' => '#22c55e', 'capacity' => 200, 'sort_order' => 2], + ], + 'scheduled' => 8, + 'unscheduled' => 4, + 'pool_offset' => 50, + 'start_hour' => 11, + 'end_hour' => 18, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); + $this->command->info(' Braderie Dorpstown 2026 complete'); }); } @@ -1206,6 +1232,18 @@ class DevSeeder extends Seeder $formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + + // Stages + timetable (series, 4 weekend editions) + $tt = ArtistTimetableDevSeeder::seedForSeries($this->org, $ijsbaan, $weeks, $this->artistPool, [ + 'stages' => [ + ['name' => 'Schaatspaviljoen', 'color' => '#0ea5e9', 'capacity' => 800, 'sort_order' => 1], + ['name' => 'Verwarmde Tent', 'color' => '#22c55e', 'capacity' => 300, 'sort_order' => 2], + ], + 'per_week_scheduled' => 3, + 'per_week_unscheduled' => 2, + 'pool_offset' => 65, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); }); } @@ -1378,6 +1416,24 @@ class DevSeeder extends Seeder $formSchema = FormBuilderDevSeeder::seedEventSchema($koningsdag); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + + // Stages + timetable (closed event — most acts contracted+done, + // but a small backlog of unscheduled engagements lives in the + // pool to demo "linked, not slotted" alongside historical data.) + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $koningsdag, $this->artistPool, [ + 'stages' => [ + ['name' => 'Erasmusbrug Podium', 'color' => '#e85d75', 'capacity' => 2000, 'sort_order' => 1], + ['name' => 'Willemsplein Stage', 'color' => '#0ea5e9', 'capacity' => 1500, 'sort_order' => 2], + ['name' => 'Oude Haven Floater', 'color' => '#7c3aed', 'capacity' => 600, 'sort_order' => 3], + ], + 'scheduled' => 12, + 'unscheduled' => 4, + 'pool_offset' => 90, + 'start_hour' => 12, + 'end_hour' => 23, + 'force_status' => \App\Enums\Artist\ArtistEngagementStatus::Contracted, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); }); } @@ -1410,6 +1466,21 @@ class DevSeeder extends Seeder FormBuilderDevSeeder::seedEventSchema($event); + // Stages + minimal timetable (draft event — 4 acts in option/draft + // status with a couple already slotted, plus a few unscheduled). + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $event, $this->artistPool, [ + 'stages' => [ + ['name' => 'Deliplein', 'color' => '#7c3aed', 'capacity' => 500, 'sort_order' => 1], + ['name' => 'Kaapse Bar', 'color' => '#22c55e', 'capacity' => 200, 'sort_order' => 2], + ], + 'scheduled' => 4, + 'unscheduled' => 5, + 'pool_offset' => 110, + 'start_hour' => 21, + 'end_hour' => 26, + ]); + $this->command->info(" Draft event: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); + $this->command->info(' Empty draft event created (with form schema, 0 submissions)'); }); } diff --git a/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php index 184122f8..99d2aa3e 100644 --- a/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php +++ b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Tests\Feature\Artist; use App\Models\Artist; -use App\Models\ArtistContact; use App\Models\ArtistEngagement; use App\Models\Event; use App\Models\Genre; @@ -22,9 +21,31 @@ final class ArtistTimetableDevSeederTest extends TestCase { use RefreshDatabase; - public function test_seeder_produces_expected_fixture_counts(): void + public function test_organisation_pool_creates_125_artists_and_8_genres(): void { $org = Organisation::factory()->create(); + + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + $this->assertSame(8, Genre::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertSame(125, Artist::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertCount(8, $pool['genres']); + $this->assertCount(125, $pool['artists']); + + // Every artist belongs to the seeded organisation + $this->assertSame( + 125, + Artist::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $org->id) + ->count(), + ); + } + + public function test_seed_for_festival_produces_stages_engagements_and_unscheduled(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + /** @var Event $festival */ $festival = Event::factory()->for($org)->festival()->create([ 'start_date' => '2026-07-10', @@ -43,18 +64,98 @@ final class ArtistTimetableDevSeederTest extends TestCase 'end_date' => '2026-07-12', ]); - ArtistTimetableDevSeeder::seedForFestival($org, $festival, [$vrijdag, $zaterdag, $zondag]); + $result = ArtistTimetableDevSeeder::seedForFestival( + $org, + $festival, + [$vrijdag, $zaterdag, $zondag], + $pool, + ); - $this->assertSame(4, Genre::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(4, Stage::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(12, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(6, Artist::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(6, ArtistContact::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(12, ArtistEngagement::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(13, Performance::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame( + // 5 stages, each linked to all 3 sub-events via stage_days + $this->assertSame(5, Stage::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertSame(15, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); + + // Engagement / performance counts (deterministic from the loop structure) + $this->assertSame(45, $result['engagements']); + $this->assertSame(12, $result['unscheduled']); + $this->assertGreaterThanOrEqual(35, $result['performances']); + + // At least one parked performance for the wachtrij UI + $this->assertGreaterThanOrEqual( 1, - Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count() + Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count(), + ); + + // Unscheduled engagements really have no performances + $unscheduledQuery = ArtistEngagement::withoutGlobalScope(OrganisationScope::class) + ->doesntHave('performances'); + $this->assertSame(12, $unscheduledQuery->count()); + } + + public function test_seed_for_flat_event_creates_stages_and_mixed_engagements(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + /** @var Event $event */ + $event = Event::factory()->for($org)->create([ + 'event_type' => 'event', + 'start_date' => '2026-04-27', + 'end_date' => '2026-04-27', + ]); + + $result = ArtistTimetableDevSeeder::seedForFlatEvent($org, $event, $pool, [ + 'scheduled' => 6, + 'unscheduled' => 3, + 'pool_offset' => 0, + ]); + + $this->assertSame(2, $result['stages']); + $this->assertSame(9, $result['engagements']); + $this->assertSame(3, $result['unscheduled']); + $this->assertSame(6, $result['performances']); + + // Stage days for the flat event (1 day × 2 stages = 2 rows) + $this->assertSame( + 2, + StageDay::withoutGlobalScope(OrganisationScope::class) + ->where('event_id', $event->id) + ->count(), ); } + + public function test_seed_for_series_creates_stages_with_stage_days_per_sub_event(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + /** @var Event $parent */ + $parent = Event::factory()->for($org)->create([ + 'event_type' => 'series', + 'start_date' => '2026-12-05', + 'end_date' => '2027-01-25', + ]); + $subs = []; + foreach (['2026-12-06', '2026-12-20', '2027-01-04', '2027-01-25'] as $date) { + $subs[] = Event::factory()->for($org)->subEvent($parent)->create([ + 'start_date' => $date, + 'end_date' => $date, + ]); + } + + $result = ArtistTimetableDevSeeder::seedForSeries($org, $parent, $subs, $pool, [ + 'per_week_scheduled' => 2, + 'per_week_unscheduled' => 1, + 'pool_offset' => 0, + ]); + + // 2 stages × 4 sub-events = 8 stage_days + $this->assertSame(2, $result['stages']); + $this->assertSame(8, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); + + // 4 weeks × (2 scheduled + 1 unscheduled) = 12 engagements + $this->assertSame(12, $result['engagements']); + $this->assertSame(4, $result['unscheduled']); + $this->assertSame(8, $result['performances']); + } }