feat(timetable): AdvanceSectionObserver — keep advancing_*_count in sync

Closes ART-OBSERVER-ADVANCE-AGGREGATE. Recomputes
artist_engagements.advancing_completed_count + advancing_total_count
on every section lifecycle event (created / updated-status-only /
deleted). Atomic via DB::transaction + lockForUpdate on both the
parent engagement and the sibling section rows; concurrent section-
status changes serialise correctly. Counter updates use
disableLogging() — counter sync is housekeeping, not audit. The
section's own updated event continues to log via LogsActivity on
AdvanceSection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 22:12:34 +02:00
parent 3ed793d58e
commit 1716e090e0
2 changed files with 98 additions and 1 deletions

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Models\AdvanceSection;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Support\Facades\DB;
/**
* Observer for AdvanceSection.
*
* Closes ART-OBSERVER-ADVANCE-AGGREGATE: keeps
* `artist_engagements.advancing_completed_count` and
* `advancing_total_count` in sync with the actual section state.
*
* Recomputes on every relevant lifecycle event:
* - created totals always change
* - updated only when submission_status changed (label/sort_order
* edits do not affect counters)
* - deleted totals always change (parent may already be soft-
* deleted via ArtistEngagementObserver cascade; in that
* case the recompute is a no-op since the engagement is
* gone guarded explicitly).
*
* Race-condition guarantee: two simultaneous section-status changes
* for the same engagement could read identical pre-state and write
* conflicting counter values. The recompute uses lockForUpdate inside
* a DB::transaction so the second writer waits for the first to
* commit, then re-reads from the locked rows. This is the same
* concurrency idiom documented in the runbook deel 5 for any
* aggregate-counter observer.
*
* The engagement update inside recompute() calls disableLogging()
* counter sync is housekeeping, not an audit-worthy event. The
* section's own updated event is logged via LogsActivity on the
* AdvanceSection model.
*/
final class AdvanceSectionObserver
{
public function created(AdvanceSection $section): void
{
$this->recompute($section);
}
public function updated(AdvanceSection $section): void
{
if (! $section->wasChanged('submission_status')) {
return;
}
$this->recompute($section);
}
public function deleted(AdvanceSection $section): void
{
$this->recompute($section);
}
private function recompute(AdvanceSection $section): void
{
$engagementId = $section->engagement_id;
DB::transaction(function () use ($engagementId): void {
$engagement = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagementId)
->lockForUpdate()
->first();
if ($engagement === null) {
return;
}
$rows = AdvanceSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('engagement_id', $engagementId)
->lockForUpdate()
->get(['submission_status']);
$total = $rows->count();
$completed = $rows
->where('submission_status', AdvanceSectionSubmissionStatus::Approved)
->count();
$engagement->disableLogging();
$engagement->update([
'advancing_completed_count' => $completed,
'advancing_total_count' => $total,
]);
});
}
}

View File

@@ -164,9 +164,10 @@ class AppServiceProvider extends ServiceProvider
FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class); FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class);
// RFC-TIMETABLE v0.2 — engagement denorm + cross-tenant guard, // RFC-TIMETABLE v0.2 — engagement denorm + cross-tenant guard,
// performance optimistic-lock bump. // performance optimistic-lock bump, advance-section counter sync.
\App\Models\ArtistEngagement::observe(\App\Observers\ArtistEngagementObserver::class); \App\Models\ArtistEngagement::observe(\App\Observers\ArtistEngagementObserver::class);
\App\Models\Performance::observe(\App\Observers\PerformanceObserver::class); \App\Models\Performance::observe(\App\Observers\PerformanceObserver::class);
\App\Models\AdvanceSection::observe(\App\Observers\AdvanceSectionObserver::class);
// RFC-WS-6 v1.3 §Q1 — FormSubmissionSubmitted listener layout. // RFC-WS-6 v1.3 §Q1 — FormSubmissionSubmitted listener layout.
// //