fix(booking): carry the clinic context through the panel and drop phantom locations

Two faults, one root: the per-context booking work updated ScheduleSection but
left the rest of the panel calling slot endpoints without clinic_uuid. Absent
clinic_uuid means the personal practice, so the panel asked about a schedule the
doctor barely uses and got nothing back.

- useClinicContext() resolves the current environment once and is used by the
  appointments page, useDoctorBookingServices, ServiceSlotPicker and both
  queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It
  returns null in a doctor's personal environment so the mirror-image bug — a
  doctor seeing the clinic's schedule at their own practice — cannot appear.
  clinicUuid is part of every query key; without it the cache leaks across
  environments.
- appointment-slots returns empty_reason (no_schedule | holiday | day_off |
  outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day,
  which is what the bug report actually saw; it now says which of the four it is.
- booking-locations lists a location only when the context has an address and an
  active shift points at it. The dev data had three "personal" schedules whose
  shifts referenced the clinic's address, so the public site advertised a
  personal practice that could never be booked.
- ?date= adds available_on_date per location, validated as a real calendar date.
- MyAppointmentsController and AdminApiController resolved the appointment
  address with no context and could store the wrong one. Both now go through the
  new BookingContextResolver, which also replaces AppointmentController's private
  copy of the same membership check.
- app:schedule:audit-locations reports shifts pointing at a missing or foreign
  address; --fix deactivates them rather than deleting.

Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions,
with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu.

Suite: 417 tests, 2 failures — both pre-existing and unrelated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 14:35:31 +03:30
co-authored by Claude Opus 4.8
parent 8d31ebb3cb
commit 7a0654f8ba
15 changed files with 906 additions and 42 deletions
+46 -1
View File
@@ -701,6 +701,8 @@ Every public booking endpoint therefore accepts an optional **`clinic_uuid`**:
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query |
| `POST /api/v1/appointment` | body |
| `POST /api/v1/my/appointment` | body |
| admin booking (`src/Admin/`) | body |
Omitting it means the **personal practice** — it is never a wildcard. If the doctor is not a member
of the given clinic → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»). All four `GET`s echo back
@@ -771,7 +773,8 @@ location — picking one and hiding the rest removes real capacity from the doct
| `location_uuid` | `string\|null` | the `DoctorAddress` uuid; `null` when the context has no address yet |
| `type` | `"personal" \| "clinic"` | |
| `booking_mode` | `"slot" \| "service"` | per-context — the same doctor can differ between locations |
| `opening_hours` | `array` | active weekly shifts of that context, flattened; `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification` |
| `opening_hours` | `array` | active weekly shifts of that context, flattened; each entry is `{day, day_index, location_id, opens, closes}`. `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification`; `day_index` is the schedule key (0=Saturday) |
| `available_on_date` | `bool\|null` | only when `?date=` is supplied — whether that location has a free slot that day. `null` without `date` |
| `services` | `array` | populated only in `service` mode, scoped to that context's owner |
| `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days, capped by the context's booking window |
@@ -786,6 +789,29 @@ selection; locations with no capacity sort last. Deep links should carry the cho
**Status codes:** `200`, `404 ERR_VALIDATION_002` (doctor not found).
#### A location must be bookable to be listed
An entry is returned only when **both** hold:
1. the context has at least one registered address, **and**
2. at least one active shift points at one of those addresses.
A schedule whose shifts carry no `location_id`, or point at an address belonging to a different
context (a personal schedule referencing a clinic address, say), is not a place a patient can go —
it is omitted entirely, and its shifts never appear in `opening_hours`.
This filters out rows that predate the `validateSessions` rule, which now rejects such shifts at
write time. Use `php bin/console app:schedule:audit-locations` to list the offenders; `--fix`
deactivates them (it never deletes — the hours are user data). If the schedule was in truth a
clinic's, move it instead with `app:schedule:assign-clinic`.
#### `?date=YYYY-MM-DD`
Adds `available_on_date` to every entry and echoes `date` in the response. Use it to grey out
locations that cannot be booked on the day the patient picked, rather than showing an empty slot
list. An impossible date (`2026-13-99`) → `422 ERR_VALIDATION_001` on field `date`; the check is a
real calendar check, not just a regex.
`opening_hours` lists one entry per active shift, so a day with a morning and an evening shift
appears twice. Days with no active shift are absent. Times are local `HH:MM` strings, and the
per-shift `location_id` is not repeated here — every shift in an entry already belongs to that
@@ -794,3 +820,22 @@ location's context.
**Consumer:** `nobat724_front` — the doctor page must render one booking block per entry, pass the
matching `clinic_uuid` into the slot and booking calls, and feed `opening_hours` into the
`openingHoursSpecification` of each `MedicalClinic` in the Physician JSON-LD.
---
## Why a day has no slots — `empty_reason` (2026-07)
`GET /api/v1/appointment-slots` now returns `empty_reason` alongside `sessions`. It is `null` when
sessions exist, otherwise one of:
| Value | Meaning |
|---|---|
| `no_schedule` | no weekly schedule exists for that **context** — the commonest cause is a caller that forgot `clinic_uuid` and so asked about the personal practice |
| `holiday` | an active holiday covers the day (the doctor's own global holiday, or one the clinic set) |
| `day_off` | a schedule exists but that weekday has no active shift |
| `outside_window` | the date is past, beyond the booking window, or online booking is switched off |
Clients must not translate an empty `sessions` array into "closed". The admin panel used to do
exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly
active — the request simply carried no `clinic_uuid`.