fix(appointment): make resource bookings independent of the doctor's calendar

Booking a device is not booking its doctor: the operator runs it and the doctor
only supervises. But bookAtomically locked the doctor row and isSlotTaken checked
overlap against the doctor alone, ignoring which resource was chosen, so a clinic
whose devices share one supervisor could not run two of them at once. Every
tenant in the database is in that position — clinic 2's six resources all point
at doctor 6.

Resource bookings now skip the doctor lock and carry no active_slot_key; their
guarantee comes from resource_occupancy, which understands capacity and seats.
Both direct paths write occupancy rows the way the hold engine already did, so
ResourceBookingSlotService stops being the only thing holding two sources of
truth together, and cancelling releases the seat.

Occupancy is bucketed in five-minute slices, which is coarser than a booking
time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and
collided with the next one starting at that same second, despite zero real
overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings
now snap both ends of their window down to the bucket grid — schedule-driven
slots are already aligned, so only manually entered times move.

The seat is claimed after persist because it needs the appointment id; losing
the race removes the appointment rather than leaving a booking with no device
behind it.

app:appointment:backfill-resource-occupancy gives existing resource-backed
appointments their missing occupancy and clears the doctor keys that no longer
mean anything. It reports conflicts between two old bookings instead of picking
a loser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 17:57:00 +03:30
co-authored by Claude Opus 5
parent 12c1d2cbf4
commit 9a95bc59d4
11 changed files with 660 additions and 60 deletions
@@ -27,9 +27,23 @@ to union by hand. Every later fix would then have to be written twice.
## Consequences
The panel path gains a database-level guard it never had: today its only resource check is an
application-level `isFree()` call with no constraint behind it, so two concurrent requests can both
pass it. Unifying on occupancy also lets `busyIntervals` stop reading two sources.
The panel path gains a database-level guard it never had: its only resource check was an
application-level `isFree()` call with no constraint behind it, so two concurrent requests could both
pass it.
Any booking path that omits the resource keeps the doctor key and the doctor lock. Those paths must
be enumerated when this lands, so none of them silently ends up with weaker protection.
Occupancy is tracked in five-minute buckets, which is coarser than a booking time. Two appointments
that merely touch the same bucket collide even with zero real overlap — a booking ending 12:35:04 and
the next starting 12:35:04 shared one bucket and the second was rejected. Every schedule-driven
booking was already aligned to the grid; only "ثبت خارج از برنامه" produced off-grid seconds. So a
resource booking now snaps both ends of its window down to the bucket grid, which makes the guarantee
exact without changing any behaviour that was already correct. Rounding the end *up* was tried first
and is wrong: it pushes every appointment into the next one's opening bucket.
Booking paths that carry no resource keep the doctor key and the doctor lock. As of this change they
are: the admin path (`AdminApiController`), the hold/engine path (`BookingController`, which is
guarded by occupancy instead), and any doctor-only booking on the public and panel paths.
`app:appointment:backfill-resource-occupancy` gives existing resource-backed appointments the
occupancy rows they never had and clears their now-meaningless doctor keys. It reports rather than
resolves genuine conflicts between two old bookings, because choosing which one loses is not a
decision a migration should make.