fix(seeder): align ArtistTimetableDevSeeder to canonical engagement-vs-performance model (B1)

Phase A diagnosed an empty SPA timetable as a controller filter bug. B1.1's
schema-verify gate proved the opposite: the seeder violates Model A, the
controllers are correct.

Canonical model (Model A) per:
  - dev-docs/SCHEMA.md:1285  artist_engagements.event_id → festival OR flat event
  - dev-docs/SCHEMA.md:1329  performances.event_id      → sub-event OR flat event ("show host")
  - dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:1247-1257 (§10.2 contract)
    "performance.event_id must be flat event OR a sub-event of the
     engagement.event_id festival"
  - dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:455-477 (§D17)
    "Friday + Saturday under one combined deal = 1 engagement, 2 performances"
    — only works if engagement is at festival level

Controller audit (B1.2): all five filters in
api/app/Http/Controllers/Api/V1/Artist/{PerformanceController,
ArtistEngagementController, StageController}.php already match Model A.
No controller changes needed.

Seeder change (B1.3) — single consistent fix:

ArtistTimetableDevSeeder::seedForFestival now creates one engagement per
(artist, festival) instead of per (artist, sub-event). When the same artist
recurs across iterations on different sub-events, the existing engagement
is reused and another performance is added (the D17 multi-perf path).
Performances continue to carry event_id = sub-event.

Same model fix in seedForSeries (engagement at parent series, performance
at week sub-event).

seedForFlatEvent already conformed (engagement.event_id = performance.event_id
= the flat event itself).

Existence-check semantics shift from `where event_id = $subEvent->id` to
`where event_id = $festival->id` (or $parent->id for series). Numerically
the test counts hold because the bucket-cycling makes scheduled artists
distinct within the festival window.

Tests (B1.4) — new TimetableSeederControllerIntegrationTest with 7 assertions:
  - engagement.event_id is at festival level (DB invariant)
  - performance.event_id is at sub-event level (DB invariant)
  - GET /performances?day={subEvent} returns non-empty + correct event_ids
  - GET /performances unfiltered returns all sub-event performances
  - GET /performances?stage_id=null returns the seeded parked perf
  - GET /engagements returns engagements with event_id = festival
  - GET /stages returns 5 stages with event_id = festival

This locks the visible-symptom regression from Session 4: an empty SPA
timetable on a freshly-seeded festival cannot land again silently.

Existing ArtistTimetableDevSeederTest (4 tests) and the broader Artist
suite (121 tests) all stay green. composer analyse + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:37:31 +02:00
parent 74b802a803
commit 006755ac1b
2 changed files with 240 additions and 29 deletions

View File

@@ -116,26 +116,29 @@ final class ArtistTimetableDevSeeder
$hourSlots = [16, 17, 18, 19, 20, 21, 22, 23];
// Per RFC §D10/§D17 + SCHEMA.md:1285+1329:
// engagement.event_id = festival (the parent — one per artist per festival)
// performance.event_id = sub-event ("show host" day)
// Same artist on multiple sub-events shares ONE engagement (D17:
// "Friday + Saturday under one combined deal = 1 engagement, 2 performances").
foreach ($subEvents as $dayIdx => $subEvent) {
// Each sub-event gets 10 scheduled engagements across the 5 stages
// Each sub-event gets 10 scheduled performances across the 5 stages
for ($i = 0; $i < 10; $i++) {
$artist = $bucket[$bucketIdx++ % $bucket->count()];
$status = $statusMix[$statusIdx++ % count($statusMix)];
// unique(artist_id, event_id): only one engagement per
// (artist, sub-event). Since we wrap the pool, skip if
// we'd violate the constraint.
$existing = ArtistEngagement::query()
// unique(artist_id, event_id) at the festival — reuse the engagement
// if the artist was already booked for this festival, adding another
// performance under it (D17 multi-perf path).
$engagement = ArtistEngagement::query()
->where('artist_id', $artist->id)
->where('event_id', $subEvent->id)
->exists();
if ($existing) {
continue;
->where('event_id', $festival->id)
->first();
if ($engagement === null) {
$engagement = self::createEngagement($artist, $festival, $status);
$engagements++;
}
$engagement = self::createEngagement($artist, $subEvent, $status);
$engagements++;
$hour = $hourSlots[$i % count($hourSlots)];
$minute = ($i % 4) * 15;
$stage = $stages->values()[$i % $stages->count()];
@@ -160,27 +163,29 @@ final class ArtistTimetableDevSeeder
$u = 0;
for ($i = 0; $i < 12; $i++) {
$artist = $bucket[$bucketIdx++ % $bucket->count()];
$subEvent = $subEvents[$i % count($subEvents)];
// Skip if this artist already has an engagement at the festival
// (e.g. via the scheduled loop above).
$existing = ArtistEngagement::query()
->where('artist_id', $artist->id)
->where('event_id', $subEvent->id)
->where('event_id', $festival->id)
->exists();
if ($existing) {
continue;
}
self::createEngagement($artist, $subEvent, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]);
self::createEngagement($artist, $festival, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]);
$engagements++;
$unscheduled++;
}
// ── One parked performance (= scheduled but stage_id = null) ──
// Exercises the Session 4 "wachtrij" UI.
// Exercises the Session 4 "wachtrij" UI. Engagement at festival level;
// performance.event_id is the sub-event the parked slot belongs to.
$parkedArtist = $bucket[$bucketIdx++ % $bucket->count()];
$parkedSub = $subEvents[0];
if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $parkedSub->id)->exists()) {
$parkedEngagement = self::createEngagement($parkedArtist, $parkedSub, ArtistEngagementStatus::Confirmed);
if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $festival->id)->exists()) {
$parkedEngagement = self::createEngagement($parkedArtist, $festival, ArtistEngagementStatus::Confirmed);
Performance::create([
'engagement_id' => $parkedEngagement->id,
'event_id' => $parkedSub->id,
@@ -195,17 +200,17 @@ final class ArtistTimetableDevSeeder
}
// ── B2B pair on Mainstage Saturday with 3-min offset (B2B detector seed) ──
// Two artists overlap on the same stage/lane within 3 minutes.
// Two artists with consecutive performances on the same stage within 3 minutes.
$zaterdag = $subEvents[1];
$b2bA = $bucket[$bucketIdx++ % $bucket->count()];
$b2bB = $bucket[$bucketIdx++ % $bucket->count()];
$mainstage = $stages['Mainstage'] ?? $stages->first();
foreach ([['artist' => $b2bA, 'minute' => 30], ['artist' => $b2bB, 'minute' => 33]] as $pair) {
if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $zaterdag->id)->exists()) {
if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $festival->id)->exists()) {
continue;
}
$eng = self::createEngagement($pair['artist'], $zaterdag, ArtistEngagementStatus::Contracted);
$eng = self::createEngagement($pair['artist'], $festival, ArtistEngagementStatus::Contracted);
self::createPerformance($eng, $zaterdag, $mainstage, 23, $pair['minute'], 60);
$engagements++;
$performances++;
@@ -330,16 +335,22 @@ final class ArtistTimetableDevSeeder
$performances = 0;
$unscheduled = 0;
// Same model as the festival path: engagement at the parent series
// event, performance at the per-week sub-event. A resident artist
// playing multiple weeks gets ONE engagement with N performances.
foreach ($subEvents as $weekIdx => $week) {
for ($i = 0; $i < $perWeekScheduled; $i++) {
$artist = $bucket[$bucketIdx++ % $bucket->count()];
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++;
$engagement = ArtistEngagement::query()
->where('artist_id', $artist->id)
->where('event_id', $parent->id)
->first();
if ($engagement === null) {
$engagement = self::createEngagement($artist, $parent, $status);
$engagements++;
}
$stage = $stages->values()[$i % $stages->count()];
self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60);
@@ -348,11 +359,11 @@ final class ArtistTimetableDevSeeder
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()) {
if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $parent->id)->exists()) {
continue;
}
$status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3];
self::createEngagement($artist, $week, $status);
self::createEngagement($artist, $parent, $status);
$engagements++;
$unscheduled++;
}