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\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:
* - 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
{
/** @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) {
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<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
$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<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 = [];
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<array{name: string, color: string, capacity: int, sort_order: int}> $specs
* @param array<int, Event> $performanceDays
* @return Collection<string, Stage>
*/
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,
]);
}
}

View File

@@ -43,6 +43,9 @@ class DevSeeder extends Seeder
/** @var array<string, \App\Models\PersonTag> */
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
{
$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)');
});
}

View File

@@ -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']);
}
}