Files
crewli/api/app/Services/Artist/LaneCascadeService.php
bert.hausmans 996dedc11d test(timetable): Phase C — 57 new tests covering session 2 surface
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>
2026-05-08 21:07:29 +02:00

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