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:
2026-05-08 20:53:43 +02:00
parent bb1bd8361a
commit 9e94ab78d8
7 changed files with 473 additions and 0 deletions

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