Nine test files under tests/Feature/Artist/ exercising:
ArtistEngagementStateMachineTest 8 tests — terminal blocks, conditional
gates (Option/Contracted), full happy
path, cancel cascade
LaneCascadeServiceTest 5 tests — simple move, cascade-bump,
version mismatch, park, unpark
BumaVatCalculationTest 6 tests — D26 formula coverage:
Organisation/BookingAgency/NotApplicable,
VAT off, breakdown sum, zero fee
DemoteExpiredOptionsTest 4 tests — expired demote, future
untouched, non-Option untouched, run
twice → single option_expired entry
IdempotencyKey60sRedisTest 4 tests — missing header 400, first
cache, replay header, failed not cached
ArtistControllerTest 8 tests — index/create/destroy + cross-
tenant + duplicate detection + restore
StageControllerTest 7 tests — create + uniqueness, destroy
cascade-park, reorder permutation,
replaceDays orphan 409 + force_orphan
ArtistEngagementControllerTest 5 tests — index/create/update/destroy +
422 on invalid status transition
TimetableMoveControllerTest 3 tests — happy path with idempotency
header, missing header → 400, version
mismatch → 409
ArtistPolicyTest 6 tests — role checks, cross-tenant
denial, super_admin bypass, D27 active-
engagement gate
ActivityLogShapeTest 4 tests — performance.moved cascade
props, status_changed vs cancelled,
stage.day_added subject + props,
stage.reordered on Event subject
Bug fixes surfaced by Phase C:
Schema reality: events table uses `start_date`/`end_date` (date), not
`start_at`/`end_at`. Updated WithinEventBounds rule and the two stage_day
resolvers (LaneCascadeService + MoveTimetablePerformanceRequest) to
query the actual columns. ArtistResource.engagements_summary upcoming
filter likewise.
performances table has no organisation_id column (FK-chain via
engagement_id). Removed the org-id filter from the Rule::exists in
MoveTimetablePerformanceRequest; cross-tenant is caught by the policy
in TimetableMoveController.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.1 KiB
PHP
168 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Artist;
|
|
|
|
use App\Exceptions\Artist\VersionMismatchException;
|
|
use App\Models\Performance;
|
|
use App\Models\Stage;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* RFC v0.2 D18 — single transactional move algorithm.
|
|
*
|
|
* Locks the dragged Performance row (FOR UPDATE), validates the
|
|
* client's version number against the persisted one, and either parks
|
|
* the row (stage_id → null) or places it on the target stage with a
|
|
* cascade-bump of any time-overlapping rows on the same lane. Cascade
|
|
* is single-level: every performance currently on
|
|
* (target_stage, target_event, target_lane) whose [start, end] window
|
|
* overlaps the new placement gets bumped to lane+1. Higher-lane chain
|
|
* collisions resolve themselves in a subsequent move; the prototype
|
|
* audit (§5) shows that single-level bumping is sufficient because
|
|
* collisions are rare and the user is editing one block at a time.
|
|
*
|
|
* The PerformanceObserver auto-bumps `version` on save so we don't
|
|
* need to set it explicitly here.
|
|
*/
|
|
final class LaneCascadeService
|
|
{
|
|
/**
|
|
* @return MoveResult moved + (other) cascaded performances
|
|
*
|
|
* @throws VersionMismatchException when client version is stale
|
|
*/
|
|
public function move(
|
|
Performance $performance,
|
|
?Stage $targetStage,
|
|
?CarbonImmutable $start,
|
|
?CarbonImmutable $end,
|
|
?int $targetLane,
|
|
int $clientVersion,
|
|
): MoveResult {
|
|
return DB::transaction(function () use (
|
|
$performance,
|
|
$targetStage,
|
|
$start,
|
|
$end,
|
|
$targetLane,
|
|
$clientVersion,
|
|
): MoveResult {
|
|
/** @var Performance $locked */
|
|
$locked = Performance::query()
|
|
->whereKey($performance->id)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
if ((int) $locked->version !== $clientVersion) {
|
|
throw new VersionMismatchException(
|
|
currentVersion: (int) $locked->version,
|
|
clientVersion: $clientVersion,
|
|
);
|
|
}
|
|
|
|
if ($targetStage === null) {
|
|
$locked->stage_id = null;
|
|
if ($start !== null) {
|
|
$locked->start_at = $start;
|
|
}
|
|
if ($end !== null) {
|
|
$locked->end_at = $end;
|
|
}
|
|
if ($targetLane !== null) {
|
|
$locked->lane = $targetLane;
|
|
}
|
|
$locked->save();
|
|
|
|
return new MoveResult($locked->refresh(), []);
|
|
}
|
|
|
|
if ($start === null || $end === null || $targetLane === null) {
|
|
throw new \InvalidArgumentException(
|
|
'targetStage non-null requires start, end and targetLane.',
|
|
);
|
|
}
|
|
|
|
$newEventId = $this->resolveEventIdForStageAndStart($targetStage, $start)
|
|
?? $performance->event_id;
|
|
|
|
// Lock all peer performances on (stage, event, lane) for the
|
|
// duration of the cascade. The first iteration locks the
|
|
// already-existing rows; new placements that follow this
|
|
// transaction wait on these row locks until commit.
|
|
$existingOnLane = Performance::query()
|
|
->where('stage_id', $targetStage->id)
|
|
->where('event_id', $newEventId)
|
|
->where('lane', $targetLane)
|
|
->where('id', '!=', $locked->id)
|
|
->lockForUpdate()
|
|
->get();
|
|
|
|
$cascaded = [];
|
|
foreach ($existingOnLane as $other) {
|
|
if ($this->overlaps($start, $end, $other->start_at, $other->end_at)) {
|
|
$other->lane = (int) $other->lane + 1;
|
|
$other->save();
|
|
$cascaded[] = $other->refresh();
|
|
}
|
|
}
|
|
|
|
$locked->stage_id = $targetStage->id;
|
|
$locked->event_id = $newEventId;
|
|
$locked->start_at = $start;
|
|
$locked->end_at = $end;
|
|
$locked->lane = $targetLane;
|
|
$locked->save();
|
|
|
|
$moved = $locked->refresh();
|
|
|
|
// RFC §8 — single parent activity entry summarising the
|
|
// cascade. The per-row updates (lane bumps) still flow
|
|
// through the model's auto-log; this entry is the audit
|
|
// anchor for the whole transactional move.
|
|
activity('performance')
|
|
->performedOn($moved)
|
|
->event('moved')
|
|
->withProperties([
|
|
'cascade_count' => count($cascaded),
|
|
'cascaded_ids' => array_map(fn ($p): string => (string) $p->id, $cascaded),
|
|
'event_id' => $moved->event_id,
|
|
'organisation_id' => $moved->engagement?->organisation_id,
|
|
])
|
|
->log('moved');
|
|
|
|
return new MoveResult($moved, $cascaded);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* For festivals, `target_start_at` falls inside one of the stage's
|
|
* sub-events; for flat events, the stage's own event_id is the
|
|
* answer. Returns null if no stage_day matches — caller falls back
|
|
* to keeping the performance's existing event_id (validation rules
|
|
* in the FormRequest already guarantee a valid stage_day exists).
|
|
*/
|
|
private function resolveEventIdForStageAndStart(Stage $stage, CarbonImmutable $start): ?string
|
|
{
|
|
return $stage->stageDays()
|
|
->join('events', 'events.id', '=', 'stage_days.event_id')
|
|
->where('events.start_date', '<=', $start->toDateString())
|
|
->where('events.end_date', '>=', $start->toDateString())
|
|
->orderBy('events.start_date', 'desc')
|
|
->limit(1)
|
|
->value('stage_days.event_id');
|
|
}
|
|
|
|
private function overlaps(
|
|
CarbonImmutable $aStart,
|
|
CarbonImmutable $aEnd,
|
|
\DateTimeInterface $bStart,
|
|
\DateTimeInterface $bEnd,
|
|
): bool {
|
|
return $aStart < CarbonImmutable::instance($bEnd)
|
|
&& CarbonImmutable::instance($bStart) < $aEnd;
|
|
}
|
|
}
|