# Resource bookings are guarded by occupancy, not by the doctor

Booking a resource is independent of the doctor's calendar: a laser device has its own availability
and the doctor attached to it only supervises. The booking code does not reflect that.
`bookAtomically` takes a pessimistic lock on the doctor and rejects any interval that overlaps
another booking of the same doctor, ignoring which resource was chosen, so a clinic whose devices
share one supervising doctor cannot run two of them at once. In the current database every tenant is
in that position — clinic 2's six resources all point at doctor 6, clinic 3's three at doctor 9.

The fix is to make resource occupancy the guard for resource bookings and stop deriving their
protection from the doctor. Concretely: the panel booking path writes `ResourceOccupancy` rows the
way the hold-based engine already does, cancellation releases them, `active_slot_key` stays null
whenever an appointment carries a resource, and doctor-level locking applies only to bookings with no
resource. The appointment's branch is then taken from the resource's own address rather than from a
matching slot in the doctor's weekly schedule.

## Considered Options

Rekeying `active_slot_key` on the resource (`r{resource_id}:{slot_start}`) was rejected because it
silently defeats `ClinicResource.capacity`: a room seating three would reject its second patient, and
a unique index knows nothing about seats, buffers, or setup and cleanup time.

Teaching `isSlotTaken` about resources was rejected as a half-measure. It leaves two booking paths
storing occupancy in two different places — `appointments.resource_id` for the panel,
`resource_occupancy` for the engine — which `ResourceBookingSlotService::busyIntervals` already has
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: its only resource check was an
application-level `isFree()` call with no constraint behind it, so two concurrent requests could both
pass it.

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.
