Add addtional test data using seeders for Artist Management module

This commit is contained in:
2026-05-09 20:06:52 +02:00
parent 3b255a36de
commit 89d137e714
3 changed files with 712 additions and 176 deletions

View File

@@ -15,195 +15,559 @@ use App\Models\Performance;
use App\Models\Stage; use App\Models\Stage;
use App\Models\StageDay; use App\Models\StageDay;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/** /**
* Seeds the Artist & Timetable fixture for an existing festival with * Seeds the org-wide artist roster + per-event timetable fixtures.
* three sub-events (e.g. Echt Feesten 2026 Vrijdag/Zaterdag/Zondag).
* *
* Reproduces the prototype audit fixture as closely as possible: * Three entry points:
* - 4 stages on the festival * - seedOrganisationPool() 8 genres + 125 artists at the organisation level
* - 4 stages × 3 sub-events = 12 stage_days rows * - seedForFestival() multi-day festival with sub-events (Echt Feesten)
* - 4 genres, 6 master artists with default genre + draw * - seedForFlatEvent() single-day event (Braderie, Koningsdag, Nacht)
* - 12 engagements with status mix (Draft 1, Requested 2, Option 3, * - seedForSeries() recurring series with weekly sub-events (IJsbaan)
* 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).
* *
* advance_sections seeding lands in Session 3 with the form-builder * Every event seeded gets:
* integration. * - 25 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 final class ArtistTimetableDevSeeder
{ {
/** @param array<int, Event> $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<array{name: string, color: string}>
*/
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<string, Genre>, artists: Collection<int, Artist>}
*/
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<int, Event> $subEvents Sub-events keyed 0..n in date order
* @param array{genres: Collection<string, Genre>, artists: Collection<int, Artist>} $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) { if (count($subEvents) < 3) {
return; return ['stages' => 0, 'engagements' => 0, 'performances' => 0, 'unscheduled' => 0];
} }
// Genres $stages = self::createStages($festival, [
$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([
['name' => 'Mainstage', 'color' => '#e85d75', 'capacity' => 4500, 'sort_order' => 1], ['name' => 'Mainstage', 'color' => '#e85d75', 'capacity' => 4500, 'sort_order' => 1],
['name' => 'Havana', 'color' => '#0ea5e9', 'capacity' => 1200, 'sort_order' => 2], ['name' => 'Havana', 'color' => '#0ea5e9', 'capacity' => 1200, 'sort_order' => 2],
['name' => 'Stairway', 'color' => '#7c3aed', 'capacity' => 800, 'sort_order' => 3], ['name' => 'Stairway', 'color' => '#7c3aed', 'capacity' => 800, 'sort_order' => 3],
['name' => 'Socialite', 'color' => '#22c55e', 'capacity' => 600, 'sort_order' => 4], ['name' => 'Socialite', 'color' => '#22c55e', 'capacity' => 600, 'sort_order' => 4],
])->mapWithKeys(fn (array $s) => [ ['name' => 'Tent op het Plein', 'color' => '#f59e0b', 'capacity' => 400, 'sort_order' => 5],
$s['name'] => Stage::create(['event_id' => $festival->id, ...$s]), ], $subEvents);
]);
// stage_days — every stage active every sub-event day // Reserve the first 50 artists from the org pool for festival
foreach ($stages as $stage) { // engagements. (Other events draw from later slices to keep
foreach ($subEvents as $subEvent) { // some artists reserved for "unscheduled" / pure-pool variety.)
StageDay::create([ $bucket = $pool['artists']->slice(0, 50)->values();
'stage_id' => $stage->id, $bucketIdx = 0;
'event_id' => $subEvent->id,
$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<string, Genre>, artists: Collection<int, Artist>} $pool
* @param array{
* stages?: list<array{name: string, color: string, capacity: int, sort_order: int}>,
* 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<int, Event> $subEvents
* @param array{genres: Collection<string, Genre>, artists: Collection<int, Artist>} $pool
* @param array{
* stages?: list<array{name: string, color: string, capacity: int, sort_order: int}>,
* 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<string, Genre> */
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<string, Genre> $genres
* @return Collection<int, Artist>
*/
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 return $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], * Generate $count unique artist names by combining curated parts.
['name' => 'Rotterdam Brass', 'genre' => 'Live band', 'draw' => 900, 'star' => 4], * The first ~40 entries are hand-picked "real-feeling" names so the
['name' => 'Nachtwacht DJs', 'genre' => 'Techno', 'draw' => 1100, 'star' => 3], * top of the list looks human; the remainder are generated.
['name' => 'De Lichtbrigade', 'genre' => 'Live band', 'draw' => 500, 'star' => 3], *
* @return list<string>
*/
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 = []; $cores = [
foreach ($artistsData as $data) { 'Voltage', 'Echo', 'Lumen', 'Donker', 'Licht', 'Storm', 'Zon', 'Maan',
/** @var Artist $artist */ 'Stadse', 'Polder', 'Noord', 'Zuid', 'Oost', 'West', 'Maas', 'IJssel',
$artist = Artist::create([ 'Rotterdam', 'Amsterdam', 'Utrecht', 'Eindhoven', 'Brabant', 'Limburg',
'organisation_id' => $org->id, 'Kraak', 'Veer', 'Brug', 'Plein', 'Markt', 'Park', 'Tuin', 'Bos',
'name' => $data['name'], 'Ster', 'Wolk', 'Regen', 'Wind', 'Vuur', 'IJs', 'Sneeuw', 'Mist',
'default_genre_id' => $genres[$data['genre']]->id, ];
'default_draw' => $data['draw'], $suffixes = [
'star_rating' => $data['star'], 'Collective', 'Project', 'Sound', 'Live', 'Crew', 'Brothers', 'Sisters',
'home_base_country' => 'NL', 'Sessions', 'Orkest', 'Band', 'Trio', 'Quartet', 'DJs', 'System',
]); 'Allstars', 'Republic', 'Union', 'Express', 'Riders', 'Beats',
$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],
]; ];
$engagements = []; $names = $curated;
foreach ($statusPlan as $idx => $plan) { $taken = array_flip($names);
/** @var Event $subEvent */ $i = 0;
$subEvent = $subEvents[$plan['sub']]; while (count($names) < $count) {
$artist = $artists[$plan['artist']]; $core = $cores[$i % count($cores)];
$suffix = $suffixes[(int) ($i / count($cores)) % count($suffixes)];
$attrs = [ $candidate = $core.' '.$suffix;
'artist_id' => $artist->id, if (! isset($taken[$candidate])) {
'event_id' => $subEvent->id, $names[] = $candidate;
'booking_status' => $plan['status'], $taken[$candidate] = true;
'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);
} }
if ($plan['status'] === ArtistEngagementStatus::Requested) { $i++;
$attrs['requested_at'] = CarbonImmutable::now()->subDays(3); // 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 return array_slice($names, 0, $count);
// 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 * Create stages on $event and stage_days entries linking each stage
[1, 'Mainstage', 1, 23, 30, 60], // Donker & Licht — Zaterdag mainstage (B2B partner-A) * to every $performanceDay (the parent event itself for flat events,
[1, 'Mainstage', 1, 21, 0, 60], // …extra perf same engagement (D17 multi-perf) * each sub-event for festivals/series).
[2, 'Havana', 0, 21, 0, 60], *
[3, 'Havana', 1, 22, 30, 60], * @param list<array{name: string, color: string, capacity: int, sort_order: int}> $specs
[4, 'Stairway', 1, 21, 0, 75], * @param array<int, Event> $performanceDays
[5, 'Socialite', 0, 19, 0, 60], * @return Collection<string, Stage>
[6, 'Socialite', 2, 17, 0, 45], */
[7, 'Mainstage', 1, 23, 33, 60], // B2B partner-B (3-min offset → seeds B2B detector) private static function createStages(Event $event, array $specs, array $performanceDays): Collection
[9, null, 0, 0, 0, 60], // De Lichtbrigade Vrijdag = parked / wachtrij {
[10, 'Stairway', 1, 19, 0, 60], $stages = collect();
[11, 'Havana', 2, 20, 30, 60], foreach ($specs as $spec) {
[4, 'Stairway', 1, 22, 30, 60], // multi-perf on same engagement (D17) $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) { if ($status === ArtistEngagementStatus::Option) {
[$engagementIdx, $stageName, $subIdx, $hour, $minute, $minutes] = $row; $attrs['option_expires_at'] = CarbonImmutable::now()->addDays(fake()->numberBetween(7, 30));
$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::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,
]);
} }
} }

View File

@@ -43,6 +43,9 @@ class DevSeeder extends Seeder
/** @var array<string, \App\Models\PersonTag> */ /** @var array<string, \App\Models\PersonTag> */
private array $personTags = []; private array $personTags = [];
/** @var array{genres: \Illuminate\Support\Collection<string, \App\Models\Genre>, artists: \Illuminate\Support\Collection<int, \App\Models\Artist>}|null */
private ?array $artistPool = null;
public function run(): void public function run(): void
{ {
$this->call(RoleSeeder::class); $this->call(RoleSeeder::class);
@@ -163,7 +166,14 @@ class DevSeeder extends Seeder
$templatesCreated = FormBuilderDevSeeder::seedSystemTemplates($this->org); $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); FormBuilderDevSeeder::seedEventRegistrationShowcase($this->org, $festival, $this->command);
} }
// RFC-TIMETABLE v0.2 — artist + timetable fixture for the // RFC-TIMETABLE v0.2 — festival timetable: 5 stages, ~30 scheduled
// festival (4 stages, 6 artists, 12 engagements, 13 perfs). // performances over 3 days + ~12 unscheduled engagements (linked
ArtistTimetableDevSeeder::seedForFestival( // but not yet slotted on a stage/timeslot). Draws from the org pool.
$tt = ArtistTimetableDevSeeder::seedForFestival(
$this->org, $this->org,
$festival, $festival,
[$vrijdag, $zaterdag, $zondag], [$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'); $this->command->info(' Echt Feesten 2026 complete');
}); });
@@ -1031,6 +1043,20 @@ class DevSeeder extends Seeder
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); $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'); $this->command->info(' Braderie Dorpstown 2026 complete');
}); });
} }
@@ -1206,6 +1232,18 @@ class DevSeeder extends Seeder
$formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan); $formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); $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); $formSchema = FormBuilderDevSeeder::seedEventSchema($koningsdag);
$submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema);
$this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); $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); 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)'); $this->command->info(' Empty draft event created (with form schema, 0 submissions)');
}); });
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Tests\Feature\Artist; namespace Tests\Feature\Artist;
use App\Models\Artist; use App\Models\Artist;
use App\Models\ArtistContact;
use App\Models\ArtistEngagement; use App\Models\ArtistEngagement;
use App\Models\Event; use App\Models\Event;
use App\Models\Genre; use App\Models\Genre;
@@ -22,9 +21,31 @@ final class ArtistTimetableDevSeederTest extends TestCase
{ {
use RefreshDatabase; 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(); $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 */ /** @var Event $festival */
$festival = Event::factory()->for($org)->festival()->create([ $festival = Event::factory()->for($org)->festival()->create([
'start_date' => '2026-07-10', 'start_date' => '2026-07-10',
@@ -43,18 +64,98 @@ final class ArtistTimetableDevSeederTest extends TestCase
'end_date' => '2026-07-12', '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()); // 5 stages, each linked to all 3 sub-events via stage_days
$this->assertSame(4, Stage::withoutGlobalScope(OrganisationScope::class)->count()); $this->assertSame(5, Stage::withoutGlobalScope(OrganisationScope::class)->count());
$this->assertSame(12, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); $this->assertSame(15, StageDay::withoutGlobalScope(OrganisationScope::class)->count());
$this->assertSame(6, Artist::withoutGlobalScope(OrganisationScope::class)->count());
$this->assertSame(6, ArtistContact::withoutGlobalScope(OrganisationScope::class)->count()); // Engagement / performance counts (deterministic from the loop structure)
$this->assertSame(12, ArtistEngagement::withoutGlobalScope(OrganisationScope::class)->count()); $this->assertSame(45, $result['engagements']);
$this->assertSame(13, Performance::withoutGlobalScope(OrganisationScope::class)->count()); $this->assertSame(12, $result['unscheduled']);
$this->assertSame( $this->assertGreaterThanOrEqual(35, $result['performances']);
// At least one parked performance for the wachtrij UI
$this->assertGreaterThanOrEqual(
1, 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']);
}
} }