feat(timetable): API resources + LaneResolver helper
Six resources under app/Http/Resources/Api/V1/Artist/ matching
FormSubmissionResource conventions (final class, @mixin model,
optional()->toIso8601String, whenLoaded relationships).
GenreResource — id, name, color, sort_order, is_active
ArtistResource — master + lifetime/upcoming engagement counts
computed lazily from the engagements relation
ArtistContactResource — paired with ArtistResource.contacts
ArtistEngagementResource — full deal block with the RFC D26 Buma/VAT
formulas computed live in `computed.*`:
buma_amount = fee × buma_pct/100
IFF Organisation handles BUMA
vat_grondslag = fee + (buma when Organisation)
vat_amount = vat_grondslag × vat_pct/100
when vat_applicable
total_cost = fee + buma + vat + Σ breakdown
Frontend (Session 5) ports the same formula.
StageResource — adds stage_days as a flat array of event_ids
(not nested Event resources, to keep payload
light)
PerformanceResource — `lane` (raw, persisted), `lane_resolved`
(computed per D19), `warnings` (overlap +
B2B at minimum; capacity-warn refined later)
LaneResolver under app/Services/Artist/ is the pure-logic helper that
PerformanceResource calls. Greedy lowest-non-conflicting lane
assignment over the (stage_id, event_id) cohort sorted by start_at
then by raw lane (so cascade-bumped rows stay where they were
visually). Frontend port lands in Session 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
api/app/Services/Artist/LaneResolver.php
Normal file
70
api/app/Services/Artist/LaneResolver.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artist;
|
||||
|
||||
use App\Models\Performance;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* RFC v0.2 D19 — server-side lane resolution on read.
|
||||
*
|
||||
* The persisted `Performance.lane` is the cascade-bump-stable lane:
|
||||
* once a performance lands on lane N because of an earlier cascade,
|
||||
* its `lane` stays at N until the user moves it. That keeps the visual
|
||||
* order predictable across edits but it can leave gaps (lanes 0 and 2
|
||||
* occupied while 1 is empty).
|
||||
*
|
||||
* `lane_resolved` is the compact display lane: a greedy lowest-non-
|
||||
* conflicting assignment over the (stage, sub-event) group, sorted by
|
||||
* start_at. The frontend (Session 4) ports the SAME algorithm so the
|
||||
* client-side preview during a drag matches what the server would
|
||||
* write.
|
||||
*/
|
||||
final class LaneResolver
|
||||
{
|
||||
/**
|
||||
* @param iterable<Performance> $performances scoped to one
|
||||
* (stage_id, event_id)
|
||||
* group
|
||||
* @return array<string, int> perf-id → resolved lane
|
||||
*/
|
||||
public function resolve(iterable $performances): array
|
||||
{
|
||||
$list = Collection::make($performances)
|
||||
->sortBy([
|
||||
fn (Performance $p): string => (string) $p->start_at,
|
||||
fn (Performance $p): int => (int) $p->lane,
|
||||
])
|
||||
->values();
|
||||
|
||||
/** @var array<int, CarbonImmutable> $laneEnds */
|
||||
$laneEnds = [];
|
||||
$resolved = [];
|
||||
|
||||
foreach ($list as $perf) {
|
||||
$start = CarbonImmutable::instance($perf->start_at);
|
||||
$end = CarbonImmutable::instance($perf->end_at);
|
||||
|
||||
$assigned = null;
|
||||
foreach ($laneEnds as $idx => $occupiedUntil) {
|
||||
if ($start >= $occupiedUntil) {
|
||||
$assigned = $idx;
|
||||
$laneEnds[$idx] = $end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($assigned === null) {
|
||||
$assigned = count($laneEnds);
|
||||
$laneEnds[$assigned] = $end;
|
||||
}
|
||||
|
||||
$resolved[(string) $perf->id] = $assigned;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user