LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now
list the specific attributes the audit log captures (per §8 last
paragraph) instead of logFillable. Each model gets a distinct
log_name (artist / artist_engagement / stage / performance / genre)
so the activity-log filter can scope queries by domain.
tapActivity() on every model adds organisation_id (and event_id where
relevant) to the activity entry's properties. The audit-log filter in
the SPA can then query
`->where('properties->event_id', $event->id)` without joining through
multiple subject types.
Performance gets dontLogIfAttributesChangedOnly(['updated_at',
'version']) so the bookkeeping touch from PerformanceObserver doesn't
generate noise when nothing user-meaningful changed.
Custom activity events emitted by services for the cases where the
auto-log can't infer intent:
performance.moved — LaneCascadeService::move writes a single
parent entry with cascade_count and
cascaded_ids[] after the cascade-bump
commits. Per-row updates still flow
through the model trait so the audit log
shows both the summary and the diffs.
stage.day_added /
stage.day_removed — StageDayService::replaceDays writes one
entry per added/removed event_id, performed
on the parent Stage so the log groups by
stage rather than by pivot row.
stage.reordered — StageService::reorder writes one entry on
the parent Event with the full new
stage_ids[] order.
artist_engagement.
status_changed /
cancelled — ArtistEngagementService::transitionStatus
emits one of these depending on the target
status; pairs with the auto-logged `updated`
row.
The remaining artist_engagement.option_expired event lands in Step 10
when the DemoteExpiredOptions command writes a system-causer entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
8.3 KiB
PHP
234 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Artist;
|
|
|
|
use App\Enums\Artist\ArtistEngagementStatus;
|
|
use App\Enums\Artist\BumaHandledBy;
|
|
use App\Exceptions\Artist\InvalidStatusTransitionException;
|
|
use App\Models\Artist;
|
|
use App\Models\ArtistEngagement;
|
|
use App\Models\Event;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Heart of the artist booking domain. Owns the booking_status state
|
|
* machine, the deal-info defaults, and the cancellation cascade hook
|
|
* that the ArtistEngagementObserver relies on (the observer turns a
|
|
* soft-delete into a performances + advance_sections cascade; this
|
|
* service composes cancel + delete in one transaction).
|
|
*/
|
|
final class ArtistEngagementService
|
|
{
|
|
/**
|
|
* Terminal statuses — no transitions out except an explicit
|
|
* `allowReopen` flag (currently unused by callers, reserved for
|
|
* superadmin tooling).
|
|
*/
|
|
private const TERMINAL = [
|
|
ArtistEngagementStatus::Cancelled,
|
|
ArtistEngagementStatus::Rejected,
|
|
ArtistEngagementStatus::Declined,
|
|
];
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
public function create(Event $event, Artist $artist, array $attributes): ArtistEngagement
|
|
{
|
|
return DB::transaction(function () use ($event, $artist, $attributes): ArtistEngagement {
|
|
$engagement = new ArtistEngagement($attributes);
|
|
$engagement->artist_id = $artist->id;
|
|
$engagement->event_id = $event->id;
|
|
|
|
// D26 — Buma auto-flip. If artist's agent_company handles
|
|
// BUMA reporting itself, default the engagement to
|
|
// BookingAgency; otherwise default to Organisation. Caller
|
|
// may override via $attributes.
|
|
if (! array_key_exists('buma_handled_by', $attributes)) {
|
|
$artist->loadMissing('agentCompany');
|
|
$engagement->buma_handled_by = ($artist->agentCompany?->handles_buma === true)
|
|
? BumaHandledBy::BookingAgency
|
|
: BumaHandledBy::Organisation;
|
|
}
|
|
|
|
$status = $this->coerceStatus($engagement->booking_status ?? ArtistEngagementStatus::Draft);
|
|
$this->validateInitialStatus($status, $engagement);
|
|
$engagement->booking_status = $status;
|
|
|
|
$engagement->save();
|
|
|
|
return $engagement->refresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Partial update. Status changes route through transitionStatus so
|
|
* the state machine fires on every status change regardless of the
|
|
* other fields in the payload.
|
|
*
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
public function update(ArtistEngagement $engagement, array $attributes): ArtistEngagement
|
|
{
|
|
return DB::transaction(function () use ($engagement, $attributes): ArtistEngagement {
|
|
unset($attributes['organisation_id'], $attributes['artist_id'], $attributes['event_id']);
|
|
|
|
if (array_key_exists('booking_status', $attributes)) {
|
|
$newStatus = $this->coerceStatus($attributes['booking_status']);
|
|
unset($attributes['booking_status']);
|
|
$engagement->fill($attributes);
|
|
$this->transitionStatus($engagement, $newStatus, $attributes);
|
|
$engagement->save();
|
|
} else {
|
|
$engagement->fill($attributes);
|
|
$engagement->save();
|
|
}
|
|
|
|
return $engagement->refresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Atomic transition with full state-machine validation. Throws
|
|
* InvalidStatusTransitionException on illegal moves.
|
|
*
|
|
* @param array<string, mixed> $contextAttributes fields already filled on $engagement
|
|
*/
|
|
public function transitionStatus(
|
|
ArtistEngagement $engagement,
|
|
ArtistEngagementStatus $to,
|
|
array $contextAttributes = [],
|
|
): ArtistEngagement {
|
|
$from = $this->coerceStatus($engagement->booking_status);
|
|
|
|
if ($from === $to) {
|
|
return $engagement;
|
|
}
|
|
|
|
$this->validateTransition($from, $to, $engagement);
|
|
|
|
$engagement->booking_status = $to;
|
|
$engagement->save();
|
|
|
|
// RFC §8 — explicit `status_changed` audit event, separate from
|
|
// the auto-logged `updated` row that captures the diff. Pairs
|
|
// with the `cancelled` and `option_expired` events emitted by
|
|
// cancel() and the DemoteExpiredOptions command respectively.
|
|
activity('artist_engagement')
|
|
->performedOn($engagement)
|
|
->event($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed')
|
|
->withProperties([
|
|
'from' => $from->value,
|
|
'to' => $to->value,
|
|
'organisation_id' => $engagement->organisation_id,
|
|
'event_id' => $engagement->event_id,
|
|
])
|
|
->log($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed');
|
|
|
|
return $engagement;
|
|
}
|
|
|
|
/**
|
|
* Cancel = transition to Cancelled, then soft-delete. Observer
|
|
* cascades to performances + advance_sections per RFC §5.4.
|
|
*/
|
|
public function cancel(ArtistEngagement $engagement): ArtistEngagement
|
|
{
|
|
return DB::transaction(function () use ($engagement): ArtistEngagement {
|
|
$from = $this->coerceStatus($engagement->booking_status);
|
|
if ($from !== ArtistEngagementStatus::Cancelled) {
|
|
$this->transitionStatus($engagement, ArtistEngagementStatus::Cancelled);
|
|
}
|
|
$engagement->delete();
|
|
|
|
return $engagement->refresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Soft delete only. Observer cascades to children. Use cancel() if
|
|
* the booking_status should also flip — the API destroy endpoint
|
|
* routes here directly to keep status untouched (§6.2 — DELETE
|
|
* /engagements/{id} is "remove the booking", semantically distinct
|
|
* from "cancel").
|
|
*/
|
|
public function softDelete(ArtistEngagement $engagement): void
|
|
{
|
|
$engagement->delete();
|
|
}
|
|
|
|
private function validateInitialStatus(ArtistEngagementStatus $status, ArtistEngagement $engagement): void
|
|
{
|
|
if ($status === ArtistEngagementStatus::Option) {
|
|
$expires = $engagement->option_expires_at;
|
|
if ($expires === null || $expires->isPast()) {
|
|
throw new InvalidStatusTransitionException(
|
|
ArtistEngagementStatus::Draft,
|
|
$status,
|
|
'Option requires option_expires_at in the future.',
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($status === ArtistEngagementStatus::Contracted) {
|
|
$fee = $engagement->fee_amount;
|
|
if ($fee === null || (float) $fee <= 0) {
|
|
throw new InvalidStatusTransitionException(
|
|
ArtistEngagementStatus::Draft,
|
|
$status,
|
|
'Contracted requires fee_amount.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function validateTransition(
|
|
ArtistEngagementStatus $from,
|
|
ArtistEngagementStatus $to,
|
|
ArtistEngagement $engagement,
|
|
): void {
|
|
if ($from === $to) {
|
|
return;
|
|
}
|
|
|
|
if (in_array($from, self::TERMINAL, true)) {
|
|
throw new InvalidStatusTransitionException(
|
|
$from,
|
|
$to,
|
|
sprintf('%s is terminal — re-booking creates a new engagement.', $from->value),
|
|
);
|
|
}
|
|
|
|
if ($to === ArtistEngagementStatus::Option) {
|
|
$expires = $engagement->option_expires_at;
|
|
if ($expires === null || $expires->isPast()) {
|
|
throw new InvalidStatusTransitionException(
|
|
$from,
|
|
$to,
|
|
'Option requires option_expires_at in the future.',
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($to === ArtistEngagementStatus::Contracted) {
|
|
$fee = $engagement->fee_amount;
|
|
if ($fee === null || (float) $fee <= 0) {
|
|
throw new InvalidStatusTransitionException(
|
|
$from,
|
|
$to,
|
|
'Contracted requires fee_amount.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function coerceStatus(ArtistEngagementStatus|string $value): ArtistEngagementStatus
|
|
{
|
|
return $value instanceof ArtistEngagementStatus
|
|
? $value
|
|
: ArtistEngagementStatus::from($value);
|
|
}
|
|
}
|