Commit 5c5d1330 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(training): give generated sessions a branch, and reserve the pitch they...

fix(training): give generated sessions a branch, and reserve the pitch they were actually scheduled on

Three defects on one path, all silent, all found while auditing a tenant whose
sessions were invisible on every branch screen.

BranchContext::branchIdForStamping() returns null in console and queue context —
deliberately, because a cron that silently billed one branch would be worse than
an unfiltered read. But that is exactly where sessions are born: the 02:00
sessions:generate-upcoming run, and the TrainingSchedule::saved() hook. So
SessionGeneratorService and AttendanceGenerationService, which both relied on the
BelongsToBranch creating hook, have been writing branch_id NULL since they were
written. On a strictly-scoped table that is not a leak but a disappearance: the
row is in the database, counted by SQL, and on no screen in the product. It is
also why attendance:backfill exists at 02:30 — it repairs the attendance half
nightly, with the same session -> group attribution now applied at the source.

CreateAutoReservation resolved the schedule by (training_group_id, day_of_week)
and took ->first(): no start_time match, no is_active filter, no ordering. A
group that trains twice on one weekday, or that has a deactivated row sitting
beside a live one, booked whichever row sorted first — the wrong hour, or another
branch's pitch entirely. The session already records which schedule row produced
it, so use it; the (group, weekday) lookup survives only as a fallback for a
session with no schedule_id, and now matches on time and is_active.

The listener swallows its own exceptions into a log line, so none of this ever
surfaced as an error.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent ce92f8e9
......@@ -148,6 +148,12 @@ private function createIfNotExists(TrainingSession $session, string $subjectType
AttendanceRecord::create([
'academy_id' => $session->academy_id,
// Explicit for the same reason the session's own branch is: this runs from
// a queued listener, where BranchContext::branchIdForStamping() returns
// null. A branchless register is one the coach cannot open — which is what
// `attendance:backfill` exists at 02:30 to repair, using this same
// session -> group attribution.
'branch_id' => $session->branch_id ?? $session->group?->branch_id,
'training_session_id' => $session->id,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
......
......@@ -18,9 +18,22 @@ public function handle(SessionCreated $event): void
{
try {
$session = $event->session;
$schedule = TrainingSchedule::where('training_group_id', $session->training_group_id)
->where('day_of_week', $session->session_date->dayOfWeek)
->first();
// The session already records which schedule row produced it, and that is
// the only row whose facility and segments describe this session.
//
// Resolving by (group, day_of_week) instead took whichever row sorted
// first, with no time match and no is_active filter. A group that trains
// twice on one weekday, or that has a deactivated row sitting beside a
// live one, got the wrong pitch — silently, in a listener that swallows
// its own exceptions into a log line.
$schedule = $session->schedule_id
? TrainingSchedule::find($session->schedule_id)
: TrainingSchedule::where('training_group_id', $session->training_group_id)
->where('day_of_week', $session->session_date->dayOfWeek)
->where('start_time', $session->start_time)
->where('is_active', true)
->first();
if (!$schedule || !$schedule->facility_id) {
return;
......
......@@ -77,6 +77,12 @@ public function generate(TrainingGroup $group, Carbon $from, Carbon $to): int
$session = TrainingSession::create([
'academy_id' => $group->academy_id,
// Explicit, because BranchContext::branchIdForStamping() returns
// null in console and queue context — which is where nearly every
// session is born (the 02:00 cron, the schedule saved() hook). A
// null branch here is not a leak but a disappearance: the session
// is in the database, counted by SQL, and on no screen.
'branch_id' => $group->branch_id,
'training_group_id' => $group->id,
'schedule_id' => $schedule->id,
'facility_id' => $schedule->facility_id,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment