fix(appointment): one weekly schedule per doctor, place chosen per shift

A doctor working both at their own practice and at a clinic had to write two
independent schedules and neither panel could see the other, so the clinic
showed an empty form even though the doctor had configured their practice.

The schedule is now a single record owned by the doctor. What varies between
days is the place: the context of a shift is read from its location_id, not
from the record it lives in. Booking in a context therefore sees only that
context's days, so a personal-practice secretary still cannot book a clinic
day. The caller's own context decides which addresses they may assign: the
doctor gets every place of theirs, a clinic manager only its own, and shifts
outside their reach are returned for display but preserved verbatim on save.

Existing per-clinic rows are merged by migration; location_id was already
stored on every shift, so no context information is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 17:20:53 +03:30
co-authored by Claude Opus 5
parent 2f030edef1
commit a0b4a969fc
15 changed files with 789 additions and 84 deletions
+36 -8
View File
@@ -15,8 +15,20 @@ Every endpoint in this file operates inside **one booking context**, selected by
| omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses |
| a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses |
A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are
fully independent: separate sessions, separate `booking_mode` lock, separate date overrides.
**Breaking change (2026-08): a doctor holds exactly ONE weekly schedule**, no matter how many clinics
they work at. What varies from day to day is the *place*: Saturday at the personal practice, Monday at
the clinic. The context of a shift is read from the `location_id` on that shift, not from the record it
lives in. Consequences:
- `clinic_uuid` no longer selects *which record* is read or written — every context reads the same one.
- It still selects **which addresses the caller may assign**, and **which days a booking context sees**:
a personal-practice secretary never sees the clinic days and cannot book on them, and vice versa.
- A shift whose address belongs to another context is returned to the caller for display but is
preserved verbatim on save — a clinic manager can neither edit nor delete the doctor's personal shifts.
- `booking_mode` and the rest of `meta` are now doctor-wide, because there is one record. The existing
"mode is locked after the first save" rule therefore applies across contexts.
- Date overrides and holidays are unchanged and remain per-context.
Services never cross the boundary (they are polymorphic on `service_sections.entity_type`).
If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001`
@@ -43,7 +55,10 @@ Anything else → `403 ERR_AUTH_006`.
## Weekly Schedule
Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`).
Each doctor has **exactly one weekly schedule** (upsert keyed by `doctor_id`; the legacy `clinic_id`
column stays `NULL` on new rows). Rows created before 2026-08 were merged by
`migrations/Version20260820120000.php`, which appended each clinic record's sessions into the doctor's
single record — the `location_id` already on every shift carries the context.
The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions`
array.
@@ -69,7 +84,9 @@ Create or update the weekly schedule for a doctor (upsert).
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
>
> **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
> **الزام مکان:** آدرس هر شیفت باید در فهرست `available-locations` همان درخواست‌کننده باشد. پزشک هر دو محیط خودش را دارد؛ کلینیک فقط آدرس خودش. آدرس بیرون از این فهرست → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
>
> **ادغام هنگام ذخیره:** شیفت‌هایی که آدرسشان بیرون از دسترس درخواست‌کننده است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردند؛ ورودی نه می‌تواند حذفشان کند نه عوضشان.
>
> **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند.
@@ -694,8 +711,10 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses
| Field | Type | Meaning |
|---|---|---|
| `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice |
| `context` | `"personal" \| "clinic"` | convenience mirror of the above |
| `clinic_uuid` | `string\|null` | **legacy**, always `null` on a weekly schedule — the record is no longer owned by one context |
| `context` | `"personal" \| "clinic"` | **legacy**, always `"personal"` for the same reason |
| `locations` | `array` | every address of this doctor (personal + each clinic they belong to), for labelling shifts the caller may not edit — `GET` only |
| `selectable_location_ids` | `int[]` | the subset of those the **caller** may assign — `GET` only |
`DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid`
plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below).
@@ -726,8 +745,17 @@ union, because an override changes working hours and working hours are themselve
### `GET /available-locations/{doctorUuid}`
Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it,
only that clinic's addresses. The two sets are never merged (they used to be).
Returns the addresses the **caller** may assign, which is not the same as the addresses of the current
context:
| Caller | Returned |
|---|---|
| the doctor themselves, or `ROLE_ADMIN` | every address of theirs — personal **and** each clinic they belong to |
| anyone else (clinic manager, secretary) | only the addresses of the context in `?clinic_uuid=` |
The doctor gets the full set because their schedule is a single one and they move between places from
day to day; a clinic manager gets only its own so it cannot move a shift into the doctor's private
practice.
---