RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16

Merged
bert.hausmans merged 16 commits from feat/timetable-session-2 into main 2026-05-08 21:57:00 +02:00
7 changed files with 473 additions and 0 deletions
Showing only changes of commit 9e94ab78d8 - Show all commits

View File

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

View File

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

View 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(),
];
}
}

View 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(),
];
}
}

View 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));
}
}

View 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(),
];
}
}

View 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;
}
}