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:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Models\ArtistContact;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin ArtistContact
|
||||
*/
|
||||
final class ArtistContactResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'artist_id' => $this->artist_id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'role' => $this->role,
|
||||
'is_primary' => (bool) $this->is_primary,
|
||||
'receives_briefing' => (bool) $this->receives_briefing,
|
||||
'receives_infosheet' => (bool) $this->receives_infosheet,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Enums\Artist\BumaHandledBy;
|
||||
use App\Models\ArtistEngagement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin ArtistEngagement
|
||||
*
|
||||
* Buma + VAT formulas (RFC v0.2 D26 — must match Session 5 client-side
|
||||
* preview):
|
||||
*
|
||||
* buma_amount = fee × buma_percentage / 100
|
||||
* IFF buma_applicable && buma_handled_by === Organisation
|
||||
* ELSE 0
|
||||
*
|
||||
* vat_grondslag = fee + (buma_amount IF Organisation handles buma ELSE 0)
|
||||
*
|
||||
* vat_amount = vat_grondslag × vat_percentage / 100 IF vat_applicable
|
||||
* ELSE 0
|
||||
*
|
||||
* total_cost = fee + buma_amount + vat_amount
|
||||
* + Σ deal_breakdown[*].amount
|
||||
*/
|
||||
final class ArtistEngagementResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$fee = (float) ($this->fee_amount ?? 0);
|
||||
$bumaPercentage = (float) ($this->buma_percentage ?? 0);
|
||||
$vatPercentage = (float) ($this->vat_percentage ?? 0);
|
||||
|
||||
$bumaAmount = ($this->buma_applicable && $this->buma_handled_by === BumaHandledBy::Organisation)
|
||||
? round($fee * $bumaPercentage / 100, 2)
|
||||
: 0.0;
|
||||
|
||||
$vatGrondslag = $fee + (
|
||||
$this->buma_handled_by === BumaHandledBy::Organisation
|
||||
? $bumaAmount
|
||||
: 0.0
|
||||
);
|
||||
|
||||
$vatAmount = $this->vat_applicable
|
||||
? round($vatGrondslag * $vatPercentage / 100, 2)
|
||||
: 0.0;
|
||||
|
||||
$breakdownTotal = 0.0;
|
||||
foreach ((array) $this->deal_breakdown as $line) {
|
||||
if (is_array($line) && isset($line['amount'])) {
|
||||
$breakdownTotal += (float) $line['amount'];
|
||||
}
|
||||
}
|
||||
|
||||
$totalCost = round($fee + $bumaAmount + $vatAmount + $breakdownTotal, 2);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'artist_id' => $this->artist_id,
|
||||
'event_id' => $this->event_id,
|
||||
'artist' => ArtistResource::make($this->whenLoaded('artist')),
|
||||
'project_leader_id' => $this->project_leader_id,
|
||||
'project_leader' => $this->whenLoaded('projectLeader', fn () => [
|
||||
'id' => $this->projectLeader?->id,
|
||||
'name' => trim(($this->projectLeader?->first_name ?? '').' '.($this->projectLeader?->last_name ?? '')),
|
||||
'email' => $this->projectLeader?->email,
|
||||
]),
|
||||
'booking_status' => [
|
||||
'value' => $this->booking_status?->value,
|
||||
'label' => $this->booking_status?->label(),
|
||||
],
|
||||
'fee_amount' => $this->fee_amount,
|
||||
'fee_currency' => $this->fee_currency,
|
||||
'fee_type' => [
|
||||
'value' => $this->fee_type?->value,
|
||||
'label' => $this->fee_type?->label(),
|
||||
],
|
||||
'buma_applicable' => (bool) $this->buma_applicable,
|
||||
'buma_percentage' => $this->buma_percentage,
|
||||
'buma_handled_by' => [
|
||||
'value' => $this->buma_handled_by?->value,
|
||||
'label' => $this->buma_handled_by?->label(),
|
||||
],
|
||||
'vat_applicable' => (bool) $this->vat_applicable,
|
||||
'vat_percentage' => $this->vat_percentage,
|
||||
'deal_breakdown' => $this->deal_breakdown,
|
||||
'deposit_percentage' => $this->deposit_percentage,
|
||||
'deposit_due_date' => optional($this->deposit_due_date)->toIso8601String(),
|
||||
'balance_due_date' => optional($this->balance_due_date)->toIso8601String(),
|
||||
'payment_status' => [
|
||||
'value' => $this->payment_status?->value,
|
||||
'label' => $this->payment_status?->label(),
|
||||
],
|
||||
'crew_count' => $this->crew_count,
|
||||
'guests_count' => $this->guests_count,
|
||||
'requested_at' => optional($this->requested_at)->toIso8601String(),
|
||||
'option_expires_at' => optional($this->option_expires_at)->toIso8601String(),
|
||||
'advance_open_from' => optional($this->advance_open_from)->toIso8601String(),
|
||||
'advance_open_to' => optional($this->advance_open_to)->toIso8601String(),
|
||||
'advancing_completed_count' => $this->advancing_completed_count,
|
||||
'advancing_total_count' => $this->advancing_total_count,
|
||||
'notes' => $this->notes,
|
||||
'computed' => [
|
||||
'buma_amount' => $bumaAmount,
|
||||
'vat_grondslag' => $vatGrondslag,
|
||||
'vat_amount' => $vatAmount,
|
||||
'breakdown_total' => $breakdownTotal,
|
||||
'total_cost' => $totalCost,
|
||||
],
|
||||
'performances' => PerformanceResource::collection($this->whenLoaded('performances')),
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
||||
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
api/app/Http/Resources/Api/V1/Artist/ArtistResource.php
Normal file
69
api/app/Http/Resources/Api/V1/Artist/ArtistResource.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Models\Artist;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Artist
|
||||
*/
|
||||
final class ArtistResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$lifetime = $this->engagements()
|
||||
->whereNotIn('booking_status', [
|
||||
ArtistEngagementStatus::Cancelled->value,
|
||||
ArtistEngagementStatus::Rejected->value,
|
||||
ArtistEngagementStatus::Declined->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$upcoming = $this->engagements()
|
||||
->whereNotIn('booking_status', [
|
||||
ArtistEngagementStatus::Cancelled->value,
|
||||
ArtistEngagementStatus::Rejected->value,
|
||||
ArtistEngagementStatus::Declined->value,
|
||||
])
|
||||
->whereHas('event', fn ($q) => $q->where('end_at', '>=', now()))
|
||||
->count();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'default_genre_id' => $this->default_genre_id,
|
||||
'default_genre' => GenreResource::make($this->whenLoaded('defaultGenre')),
|
||||
'default_draw' => $this->default_draw,
|
||||
'star_rating' => $this->star_rating,
|
||||
'home_base_country' => $this->home_base_country,
|
||||
'agent_company_id' => $this->agent_company_id,
|
||||
'agent_company' => $this->whenLoaded(
|
||||
'agentCompany',
|
||||
fn () => [
|
||||
'id' => $this->agentCompany?->id,
|
||||
'name' => $this->agentCompany?->name,
|
||||
'handles_buma' => (bool) ($this->agentCompany?->handles_buma ?? false),
|
||||
],
|
||||
),
|
||||
'notes' => $this->notes,
|
||||
'contacts' => ArtistContactResource::collection($this->whenLoaded('contacts')),
|
||||
'engagements_summary' => [
|
||||
'lifetime_count' => $lifetime,
|
||||
'upcoming_count' => $upcoming,
|
||||
],
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
||||
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
api/app/Http/Resources/Api/V1/Artist/GenreResource.php
Normal file
32
api/app/Http/Resources/Api/V1/Artist/GenreResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Models\Genre;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Genre
|
||||
*/
|
||||
final class GenreResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'color' => $this->color,
|
||||
'sort_order' => $this->sort_order,
|
||||
'is_active' => (bool) $this->is_active,
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
109
api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php
Normal file
109
api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Models\Performance;
|
||||
use App\Services\Artist\LaneResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Performance
|
||||
*/
|
||||
final class PerformanceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'engagement_id' => $this->engagement_id,
|
||||
'event_id' => $this->event_id,
|
||||
'stage_id' => $this->stage_id,
|
||||
'lane' => (int) $this->lane,
|
||||
'lane_resolved' => $this->resolveLane(),
|
||||
'start_at' => optional($this->start_at)->toIso8601String(),
|
||||
'end_at' => optional($this->end_at)->toIso8601String(),
|
||||
'version' => (int) $this->version,
|
||||
'notes' => $this->notes,
|
||||
'warnings' => $this->computeWarnings(),
|
||||
'engagement' => ArtistEngagementResource::make($this->whenLoaded('engagement')),
|
||||
'stage' => StageResource::make($this->whenLoaded('stage')),
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
||||
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed via LaneResolver over the (stage, sub-event) cohort.
|
||||
* For parked performances (stage_id = null) the persisted lane is
|
||||
* surfaced as-is — the wachtrij is a flat list, not a lane grid.
|
||||
*/
|
||||
private function resolveLane(): int
|
||||
{
|
||||
if ($this->stage_id === null) {
|
||||
return (int) $this->lane;
|
||||
}
|
||||
|
||||
$cohort = Performance::query()
|
||||
->where('stage_id', $this->stage_id)
|
||||
->where('event_id', $this->event_id)
|
||||
->get();
|
||||
|
||||
$resolved = app(LaneResolver::class)->resolve($cohort);
|
||||
|
||||
return $resolved[(string) $this->id] ?? (int) $this->lane;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC v0.2 D5 / D6 / D25 — overlap, B2B, capacity warnings.
|
||||
* Naive implementation for Session 2; refined as the timetable
|
||||
* frontend lands in Session 4.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function computeWarnings(): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
if ($this->stage_id === null) {
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
$start = CarbonImmutable::instance($this->start_at);
|
||||
$end = CarbonImmutable::instance($this->end_at);
|
||||
|
||||
$peers = Performance::query()
|
||||
->where('stage_id', $this->stage_id)
|
||||
->where('event_id', $this->event_id)
|
||||
->where('id', '!=', $this->id)
|
||||
->get();
|
||||
|
||||
foreach ($peers as $other) {
|
||||
$oStart = CarbonImmutable::instance($other->start_at);
|
||||
$oEnd = CarbonImmutable::instance($other->end_at);
|
||||
|
||||
if ($start < $oEnd && $oStart < $end && (int) $other->lane === (int) $this->lane) {
|
||||
$warnings[] = 'overlap';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($peers as $other) {
|
||||
$oEnd = CarbonImmutable::instance($other->end_at);
|
||||
$oStart = CarbonImmutable::instance($other->start_at);
|
||||
if ($oEnd->equalTo($start) || $oStart->equalTo($end)) {
|
||||
$warnings[] = 'b2b';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($warnings));
|
||||
}
|
||||
}
|
||||
36
api/app/Http/Resources/Api/V1/Artist/StageResource.php
Normal file
36
api/app/Http/Resources/Api/V1/Artist/StageResource.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1\Artist;
|
||||
|
||||
use App\Models\Stage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Stage
|
||||
*/
|
||||
final class StageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event_id' => $this->event_id,
|
||||
'name' => $this->name,
|
||||
'color' => $this->color,
|
||||
'capacity' => $this->capacity,
|
||||
'sort_order' => $this->sort_order,
|
||||
'stage_days' => $this->whenLoaded(
|
||||
'stageDays',
|
||||
fn () => $this->stageDays->pluck('event_id')->all(),
|
||||
),
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
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