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:
96
api/app/Observers/AdvanceSectionObserver.php
Normal file
96
api/app/Observers/AdvanceSectionObserver.php
Normal 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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user