Files
crewli/api/database/seeders/ArtistTimetableDevSeeder.php

574 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistContact;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Genre;
use App\Models\Organisation;
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 org-wide artist roster + per-event timetable fixtures.
*
* 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)
*
* 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
{
/**
* 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 ['stages' => 0, 'engagements' => 0, 'performances' => 0, 'unscheduled' => 0];
}
$stages = self::createStages($festival, [
['name' => 'Mainstage', 'color' => '#e85d75', 'capacity' => 4500, 'sort_order' => 1],
['name' => 'Havana', 'color' => '#0ea5e9', 'capacity' => 1200, 'sort_order' => 2],
['name' => 'Stairway', 'color' => '#7c3aed', 'capacity' => 800, 'sort_order' => 3],
['name' => 'Socialite', 'color' => '#22c55e', 'capacity' => 600, 'sort_order' => 4],
['name' => 'Tent op het Plein', 'color' => '#f59e0b', 'capacity' => 400, 'sort_order' => 5],
], $subEvents);
// Reserve the first 50 artists from the org pool for festival
// engagements. (Other events draw from later slices to keep
// some artists reserved for "unscheduled" / pure-pool variety.)
$bucket = $pool['artists']->slice(0, 50)->values();
$bucketIdx = 0;
$engagements = 0;
$performances = 0;
$unscheduled = 0;
// ── Scheduled engagements: 30 across 3 days ──
// Status mix loosely follows RFC §5.3: lots of contracted/confirmed,
// some optioned/requested, a couple of drafts and one cancelled.
$statusMix = [
ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted,
ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted,
ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed,
ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option,
ArtistEngagementStatus::Option, ArtistEngagementStatus::Requested,
];
$statusIdx = 0;
$hourSlots = [16, 17, 18, 19, 20, 21, 22, 23];
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,
]);
}
}
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',
];
$cores = [
'Voltage', 'Echo', 'Lumen', 'Donker', 'Licht', 'Storm', 'Zon', 'Maan',
'Stadse', 'Polder', 'Noord', 'Zuid', 'Oost', 'West', 'Maas', 'IJssel',
'Rotterdam', 'Amsterdam', 'Utrecht', 'Eindhoven', 'Brabant', 'Limburg',
'Kraak', 'Veer', 'Brug', 'Plein', 'Markt', 'Park', 'Tuin', 'Bos',
'Ster', 'Wolk', 'Regen', 'Wind', 'Vuur', 'IJs', 'Sneeuw', 'Mist',
];
$suffixes = [
'Collective', 'Project', 'Sound', 'Live', 'Crew', 'Brothers', 'Sisters',
'Sessions', 'Orkest', 'Band', 'Trio', 'Quartet', 'DJs', 'System',
'Allstars', 'Republic', 'Union', 'Express', 'Riders', 'Beats',
];
$names = $curated;
$taken = array_flip($names);
$i = 0;
while (count($names) < $count) {
$core = $cores[$i % count($cores)];
$suffix = $suffixes[(int) ($i / count($cores)) % count($suffixes)];
$candidate = $core.' '.$suffix;
if (! isset($taken[$candidate])) {
$names[] = $candidate;
$taken[$candidate] = true;
}
$i++;
// Safety: avoid infinite loop if combinations exhaust
if ($i > count($cores) * count($suffixes) * 2) {
$names[] = $core.' '.$suffix.' '.Str::upper(Str::random(3));
}
}
return array_slice($names, 0, $count);
}
/**
* Create stages on $event and stage_days entries linking each stage
* to every $performanceDay (the parent event itself for flat events,
* each sub-event for festivals/series).
*
* @param list<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,
];
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,
]);
}
}