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>
800 lines
27 KiB
Markdown
800 lines
27 KiB
Markdown
# Appointment Settings API
|
|
|
|
> **Prefix:** `/api/v1/appointment-settings`
|
|
> **Permission:** every endpoint requires `AUTH` and resolves access through one shared rule (below)
|
|
|
|
Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**.
|
|
|
|
## Booking context (`clinic_uuid`)
|
|
|
|
Every endpoint in this file operates inside **one booking context**, selected by the optional
|
|
`clinic_uuid` parameter (query string on `GET`/`DELETE`, body field on `POST`/`PATCH`):
|
|
|
|
| `clinic_uuid` | Context | Services usable | Addresses selectable |
|
|
|---|---|---|---|
|
|
| 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 |
|
|
|
|
**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`
|
|
(«این پزشک عضو کلینیک انتخابشده نیست»). Unknown clinic → `404 ERR_VALIDATION_002`.
|
|
|
|
## Access rule
|
|
|
|
Given the target doctor and the resolved context, access is granted when the caller is:
|
|
|
|
1. `ROLE_ADMIN`, **or**
|
|
2. the doctor themselves, **or**
|
|
3. in a **clinic context only**, someone holding that clinic's `appointment_settings` permission —
|
|
`view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md` →
|
|
*Clinic Doctor Permissions*). The clinic owner always passes this check.
|
|
|
|
Anything else → `403 ERR_AUTH_006`.
|
|
|
|
> **Breaking change (2026-07):** a clinic owner can no longer read or write a member doctor's
|
|
> **personal** schedule. Without `clinic_uuid` the request targets the personal context, which only
|
|
> the doctor and an admin may touch. The clinic panel must send `clinic_uuid`; the admin SPA already
|
|
> does (`ScheduleSection` takes a `clinicUuid` prop).
|
|
|
|
---
|
|
|
|
## Weekly Schedule
|
|
|
|
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.
|
|
|
|
### Day Index Convention
|
|
|
|
| Index | Day (EN) | Day (FA) |
|
|
|-------|----------|----------|
|
|
| `"0"` | Saturday | شنبه |
|
|
| `"1"` | Sunday | یکشنبه |
|
|
| `"2"` | Monday | دوشنبه |
|
|
| `"3"` | Tuesday | سهشنبه |
|
|
| `"4"` | Wednesday | چهارشنبه |
|
|
| `"5"` | Thursday | پنجشنبه |
|
|
| `"6"` | Friday | جمعه |
|
|
|
|
---
|
|
|
|
### POST `/api/v1/appointment-settings/weekly-schedule`
|
|
|
|
Create or update the weekly schedule for a doctor (upsert).
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره میشود.
|
|
>
|
|
> **الزام مکان:** آدرس هر شیفت باید در فهرست `available-locations` همان درخواستکننده باشد. پزشک هر دو محیط خودش را دارد؛ کلینیک فقط آدرس خودش. آدرس بیرون از این فهرست → `422 ERR_VALIDATION_001` («آدرس انتخابشده متعلق به این کلینیک نیست»).
|
|
>
|
|
> **ادغام هنگام ذخیره:** شیفتهایی که آدرسشان بیرون از دسترس درخواستکننده است، از نسخهٔ ذخیرهشده دستنخورده برمیگردند؛ ورودی نه میتواند حذفشان کند نه عوضشان.
|
|
>
|
|
> **نوبتدهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره میکند.
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{
|
|
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
"clinic_uuid": null,
|
|
"schedule": {
|
|
"0": {
|
|
"sessions": [
|
|
{
|
|
"active": true,
|
|
"location_id": 1973,
|
|
"start_time": "09:00",
|
|
"end_time": "13:00",
|
|
"duration_per_patient": 20,
|
|
"has_rest": true,
|
|
"rest_interval": 60,
|
|
"time_to_rest": 10,
|
|
"patient_limit": null
|
|
},
|
|
{
|
|
"active": true,
|
|
"location_id": 1973,
|
|
"start_time": "15:00",
|
|
"end_time": "18:00",
|
|
"duration_per_patient": 20,
|
|
"has_rest": false,
|
|
"rest_interval": 0,
|
|
"time_to_rest": 0,
|
|
"patient_limit": 5
|
|
}
|
|
]
|
|
},
|
|
"1": { "sessions": [] },
|
|
"2": { "sessions": [] },
|
|
"3": { "sessions": [] },
|
|
"4": { "sessions": [] },
|
|
"5": { "sessions": [] },
|
|
"6": { "sessions": [] }
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
|
| `schedule` | object | ✅ | Keys `"0"` through `"6"` (day indices) |
|
|
| `schedule.{n}.sessions` | array | ✅ | Array of session config objects |
|
|
| `meta` | object | ❌ | Online-booking settings (see below) |
|
|
|
|
**Online-booking `meta` object:**
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"online_booking_enabled": true,
|
|
"booking_window_value": 2,
|
|
"booking_window_unit": "month",
|
|
"booking_mode": "service",
|
|
"buffer_minutes": 5
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability |
|
|
| `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) |
|
|
| `booking_window_unit` | string | ❌ | `"day"`, `"week"` or `"month"` (invalid value keeps the current one) |
|
|
| `booking_mode` | string | ❌ | `"slot"` (پیشفرض) = نوبتدهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخابشده؛ زمانها با `GET /api/v1/appointment-service-slots` گرفته میشوند. مقدار نامعتبر نادیده گرفته میشود |
|
|
| `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبتها (دقیقه، ≥ 0). در `slot_end` ذخیره نمیشود؛ فقط فاصلهٔ بین زمانهای پیشنهادی |
|
|
|
|
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 3, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
|
|
>
|
|
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبتدهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد میشود.
|
|
>
|
|
> **غیرقابلتغییر پس از ثبت:** `booking_mode` فقط تا **اولین ثبت** قابلانتخاب است. پس از آنکه یکبار بهصورت صریح ذخیره شد (در `setting.meta.booking_mode` نوشته شد)، هر `POST`/`PATCH` که آن را تغییر دهد با `422` («نوع نوبتدهی پس از ثبت قابل تغییر نیست»، field `booking_mode`) رد میشود. پاسخِ `toArray` فیلد boolean `booking_mode_locked` را برمیگرداند (`true` = قفلشده) تا پنل توگل را غیرفعال کند. رکوردهای قدیمی که هنوز mode صریح ندارند، `booking_mode_locked=false` و یکبار قابلانتخاباند.
|
|
|
|
**Session Config Object:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `active` | boolean | ✅ | Whether this session is active |
|
|
| `location_id` | integer\|null | ❌ | Doctor address/location ID |
|
|
| `start_time` | string | ✅ | Session start `"HH:MM"` |
|
|
| `end_time` | string | ✅ | Session end `"HH:MM"` |
|
|
| `duration_per_patient` | integer | ✅ | Minutes per appointment slot |
|
|
| `has_rest` | boolean | ❌ | Whether to insert rest breaks |
|
|
| `rest_interval` | integer | ❌ | Work minutes before taking a rest break |
|
|
| `time_to_rest` | integer | ❌ | Duration of each rest break (minutes) |
|
|
| `patient_limit` | integer\|null | ❌ | Max patients per session (`null` = unlimited) |
|
|
|
|
> Multiple sessions per day are supported (e.g., morning + afternoon). Sessions are sorted by `start_time` and overlapping ones are skipped.
|
|
|
|
### Response `201`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"data": {
|
|
"uuid": "sched-uuid-...",
|
|
"doctor_uuid": "550e8400-...",
|
|
"schedule": {
|
|
"0": { "sessions": [ { "active": true, "start_time": "09:00", ... } ] },
|
|
"1": { "sessions": [] },
|
|
"2": { "sessions": [] },
|
|
"3": { "sessions": [] },
|
|
"4": { "sessions": [] },
|
|
"5": { "sessions": [] },
|
|
"6": { "sessions": [] }
|
|
},
|
|
"meta": {
|
|
"online_booking_enabled": true,
|
|
"booking_window_value": 3,
|
|
"booking_window_unit": "month",
|
|
"booking_mode": "slot",
|
|
"buffer_minutes": 0
|
|
},
|
|
"booking_mode_locked": true,
|
|
"created_at": 1717000000,
|
|
"updated_at": 1717000000
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
|
> This is an **upsert** — if a schedule already exists for the doctor, it is overwritten.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
|
|
---
|
|
|
|
### GET `/api/v1/appointment-settings/weekly-schedule/{uuid}`
|
|
|
|
Get weekly schedule. `{uuid}` can be either the **schedule UUID** or the **doctor UUID** — the controller tries both.
|
|
|
|
**Permission:** `AUTH` (class-level `IS_AUTHENTICATED_FULLY`)
|
|
|
|
### Response `200`
|
|
Same structure as POST response.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_VALIDATION_002` | 404 | Schedule not found |
|
|
|
|
---
|
|
|
|
### PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}`
|
|
|
|
Update weekly schedule. `{uuid}` can be schedule UUID or doctor UUID.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Request Body
|
|
```json
|
|
{
|
|
"schedule": {
|
|
"0": {
|
|
"sessions": [
|
|
{
|
|
"active": true,
|
|
"location_id": 1973,
|
|
"start_time": "10:00",
|
|
"end_time": "14:00",
|
|
"duration_per_patient": 30,
|
|
"has_rest": false,
|
|
"rest_interval": 0,
|
|
"time_to_rest": 0,
|
|
"patient_limit": null
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
> Replaces the entire `schedule` object if provided.
|
|
|
|
### Response `200`
|
|
Updated schedule object (same structure as POST).
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
| `ERR_VALIDATION_002` | 404 | Schedule not found |
|
|
|
|
---
|
|
|
|
### DELETE `/api/v1/booking-setting/{uuid}`
|
|
|
|
Delete a weekly schedule.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
> Note: route is `/booking-setting/`, not `/appointment-settings/`
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "message": "برنامه هفتگی با موفقیت حذف شد" } }
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
| `ERR_VALIDATION_002` | 404 | Schedule not found |
|
|
|
|
---
|
|
|
|
## Date Overrides
|
|
|
|
Override a specific date — mark it inactive (day off) or give it custom sessions.
|
|
|
|
### GET `/api/v1/appointment-settings/date-override/list/{doctorUuid}`
|
|
|
|
Get all date overrides for a doctor.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"data": [
|
|
{
|
|
"uuid": "...",
|
|
"doctor_uuid": "...",
|
|
"date": 1718476800,
|
|
"date_string": "2024-06-15",
|
|
"active": false,
|
|
"reason": "تعطیل خاص",
|
|
"custom_slots": [],
|
|
"created_at": 1717000000
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
> `date` is a Unix timestamp at **server-local midnight** (`Asia/Tehran`); `date_string` is the same day as `Y-m-d` and is what clients must render — converting the timestamp in a browser with `toISOString()` (UTC) shifts it one day back. `active: false` = entire day blocked. `active: true` with `custom_slots` = custom session schedule.
|
|
|
|
---
|
|
|
|
### POST `/api/v1/appointment-settings/date-override`
|
|
|
|
Create a date override.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Request Body
|
|
```json
|
|
{
|
|
"doctor_uuid": "550e8400-...",
|
|
"date": "2024-06-20",
|
|
"active": false,
|
|
"reason": "تعطیل رسمی",
|
|
"custom_slots": null
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
|
| `date` | string | ✅ | Date in `Y-m-d` format (e.g. `"2024-06-20"`) |
|
|
| `active` | boolean | ❌ | `false` = full day off (default); `true` = use custom_slots |
|
|
| `reason` | string | ❌ | Reason for override |
|
|
| `custom_slots` | array\|null | ❌ | Custom sessions (same SessionConfig format as weekly schedule, see below) |
|
|
|
|
**`custom_slots` format when `active: true`:**
|
|
```json
|
|
{
|
|
"custom_slots": [
|
|
{
|
|
"start_time": "14:00",
|
|
"end_time": "18:00",
|
|
"duration_per_patient": 20,
|
|
"location_id": 1973,
|
|
"has_rest": false,
|
|
"rest_interval": 0,
|
|
"time_to_rest": 0,
|
|
"patient_limit": null
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Legacy format (backward compatible):**
|
|
```json
|
|
{
|
|
"custom_slots": [
|
|
{ "start": "14:00", "end": "18:00", "duration": 20 }
|
|
]
|
|
}
|
|
```
|
|
|
|
### Response `201`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"data": {
|
|
"uuid": "override-uuid-...",
|
|
"doctor_uuid": "...",
|
|
"date": 1718476800,
|
|
"date_string": "2024-06-15",
|
|
"active": false,
|
|
"reason": "تعطیل رسمی",
|
|
"custom_slots": [],
|
|
"created_at": 1717000000
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
|
| `ERR_VALIDATION_001` | 422 | Invalid date format |
|
|
|
|
---
|
|
|
|
### GET `/api/v1/appointment-settings/date-override/{uuid}`
|
|
|
|
Get a single date override.
|
|
|
|
**Permission:** `AUTH` (class-level)
|
|
|
|
### Response `200`
|
|
Override object (same structure as above).
|
|
|
|
---
|
|
|
|
### PATCH `/api/v1/appointment-settings/date-override/{uuid}`
|
|
|
|
Update a date override.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Request Body (all optional)
|
|
```json
|
|
{
|
|
"active": true,
|
|
"reason": "جبران مرخصی",
|
|
"custom_slots": [
|
|
{
|
|
"start_time": "14:00",
|
|
"end_time": "18:00",
|
|
"duration_per_patient": 20,
|
|
"location_id": 1973,
|
|
"has_rest": false,
|
|
"rest_interval": 0,
|
|
"time_to_rest": 0,
|
|
"patient_limit": null
|
|
}
|
|
],
|
|
"date": "2024-06-21"
|
|
}
|
|
```
|
|
|
|
### Response `200`
|
|
Updated override object.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
| `ERR_VALIDATION_002` | 404 | Override not found |
|
|
|
|
---
|
|
|
|
### DELETE `/api/v1/appointment-settings/date-override/{uuid}`
|
|
|
|
Delete a date override.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "message": "Override با موفقیت حذف شد" } }
|
|
```
|
|
|
|
---
|
|
|
|
## Holidays
|
|
|
|
Mark a date range as holiday — all slots blocked, no overrides apply.
|
|
|
|
### GET `/api/v1/appointment-settings/holidays/list/{doctorUuid}`
|
|
|
|
Get all holidays for a doctor.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"data": [
|
|
{
|
|
"uuid": "...",
|
|
"doctor_uuid": "...",
|
|
"start_date": 1719792000,
|
|
"end_date": 1720656000,
|
|
"start_date_string": "2024-07-01",
|
|
"end_date_string": "2024-07-11",
|
|
"reason": "تعطیلات تابستانی",
|
|
"active": true,
|
|
"created_at": 1717000000
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
> `start_date` / `end_date` are Unix timestamps at **server-local midnight** (`Asia/Tehran`); `start_date_string` / `end_date_string` carry the same days as `Y-m-d` and are what clients must render (UTC conversion in the browser shifts them one day back). Holidays take **highest priority** — they block the day even if a date override exists.
|
|
|
|
---
|
|
|
|
### POST `/api/v1/appointment-settings/holidays`
|
|
|
|
Create a holiday range.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Request Body
|
|
```json
|
|
{
|
|
"doctor_uuid": "550e8400-...",
|
|
"start_date": "2024-07-01",
|
|
"end_date": "2024-07-10",
|
|
"reason": "تعطیلات تابستانی"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
|
| `start_date` | string | ✅ | Start date `Y-m-d` |
|
|
| `end_date` | string | ✅ | End date `Y-m-d` (must be ≥ start_date) |
|
|
| `reason` | string | ❌ | Holiday reason |
|
|
|
|
### Response `201`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"data": {
|
|
"uuid": "holiday-uuid-...",
|
|
"doctor_uuid": "...",
|
|
"start_date": 1719792000,
|
|
"end_date": 1720656000,
|
|
"start_date_string": "2024-07-01",
|
|
"end_date_string": "2024-07-11",
|
|
"reason": "تعطیلات تابستانی",
|
|
"active": true,
|
|
"created_at": 1717000000
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not the doctor owner |
|
|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
|
| `ERR_VALIDATION_001` | 422 | end_date before start_date or invalid format |
|
|
|
|
---
|
|
|
|
### GET `/api/v1/appointment-settings/holidays/{uuid}`
|
|
|
|
Get a single holiday.
|
|
|
|
**Permission:** `AUTH` (class-level)
|
|
|
|
---
|
|
|
|
### PATCH `/api/v1/appointment-settings/holidays/{uuid}`
|
|
|
|
Update a holiday.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Request Body (all optional)
|
|
```json
|
|
{
|
|
"start_date": "2024-07-02",
|
|
"end_date": "2024-07-12",
|
|
"reason": "تمدید تعطیلات",
|
|
"active": false
|
|
}
|
|
```
|
|
|
|
### Response `200`
|
|
Updated holiday object.
|
|
|
|
---
|
|
|
|
### DELETE `/api/v1/appointment-settings/holidays/{uuid}`
|
|
|
|
Delete a holiday.
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule)
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "message": "تعطیلات حذف شد" } }
|
|
```
|
|
|
|
---
|
|
|
|
## Slot Calculation Logic (Reference)
|
|
|
|
The `SlotCalculatorService` calculates available slots in this priority order:
|
|
|
|
1. **Holiday** — if date falls in a holiday range → return empty (no slots)
|
|
2. **Date Override** — if a date override exists for this date:
|
|
- `active: false` → return empty
|
|
- `active: true` → use `custom_slots` sessions
|
|
3. **Weekly Schedule** — use the day's `sessions` array (only `active: true` sessions, sorted by `start_time`, overlapping sessions skipped)
|
|
|
|
**Slot output format:**
|
|
```json
|
|
[
|
|
{
|
|
"start": 1718438400,
|
|
"end": 1718439600,
|
|
"start_time": "09:00",
|
|
"end_time": "09:20",
|
|
"location_id": 1973
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Available Locations
|
|
|
|
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
|
|
|
|
**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
|
|
|
|
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
|
|
|
|
#### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"id": "5",
|
|
"uuid": "...",
|
|
"type": "personal",
|
|
"clinic_id": null,
|
|
"clinic_name": null,
|
|
"name": "مطب شخصی",
|
|
"address": "تهران، ...",
|
|
"telephone": "09121234567",
|
|
"map": { "latitude": "35.7", "longitude": "51.4" },
|
|
"city": { "id": "1", "name": "تهران" },
|
|
"province": { "id": "8", "name": "تهران" }
|
|
},
|
|
{
|
|
"id": "12",
|
|
"uuid": "...",
|
|
"type": "clinic",
|
|
"clinic_id": "3",
|
|
"clinic_name": "کلینیک نور",
|
|
"name": "شعبه مرکزی",
|
|
"address": "تهران، خیابان ولیعصر...",
|
|
"telephone": "02112345678",
|
|
"map": { "latitude": "35.699", "longitude": "51.337" },
|
|
"city": { "id": "1", "name": "تهران" },
|
|
"province": { "id": "8", "name": "تهران" }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| `type` | `personal` = doctor's own address; `clinic` = clinic address |
|
|
| `clinic_id` | ID of the clinic (only for type=clinic) |
|
|
| `clinic_name` | Name of the clinic (only for type=clinic) |
|
|
|
|
> Use `id` as the `location_id` value in weekly schedule sessions or date override sessions.
|
|
|
|
#### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
|
|
|
|
|
---
|
|
|
|
## Context additions (2026-07)
|
|
|
|
### Response fields
|
|
|
|
`WeeklySchedule.toArray()` now also returns:
|
|
|
|
| Field | Type | Meaning |
|
|
|---|---|---|
|
|
| `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).
|
|
|
|
### Holidays are global by default
|
|
|
|
A holiday means "the doctor is not there", which is a physical fact — so unlike schedules and date
|
|
overrides it is **not** per-context by default:
|
|
|
|
| `clinic_id` | Meaning |
|
|
|---|---|
|
|
| `NULL` | the doctor is absent **everywhere** — applies to the personal practice and every clinic |
|
|
| set | the doctor is absent in that clinic only |
|
|
|
|
`GET /holidays/list/{doctorUuid}?clinic_uuid=…` returns the **union**: the clinic's own holidays plus
|
|
the doctor's global ones. Global rows come back with `editable: false` — a clinic must see that the
|
|
doctor is away but may not delete that fact.
|
|
|
|
`POST /holidays` **without** `clinic_uuid` creates a global holiday and is restricted to the doctor
|
|
themselves and admins → otherwise `403 ERR_ACCESS_DENIED` («کلینیک فقط میتواند تعطیلی مخصوص خودش را
|
|
ثبت کند»). A clinic closing for *all* its doctors is not expressible in this model and needs a
|
|
separate `ClinicHoliday` entity — **not implemented**.
|
|
|
|
### Date overrides are always per-context
|
|
|
|
`GET /date-override/list/{doctorUuid}?clinic_uuid=…` returns only that context's overrides — no
|
|
union, because an override changes working hours and working hours are themselves per-context.
|
|
|
|
### `GET /available-locations/{doctorUuid}`
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## حالت سوم: نوبتدهی منبعمحور
|
|
|
|
`meta.booking_mode` مقدار سوم `resource` را هم میپذیرد (تسک ۰۶). در این حالت نوبت به
|
|
بخشها تقسیم میشود و وقت آزاد از تقاطع تقویم منابع میآید.
|
|
|
|
| فیلد `meta` | معنی |
|
|
|---|---|
|
|
| `booking_mode: "resource"` | حالت منبعمحور |
|
|
| `step_minutes` | گام جستجوی وقت؛ پیشفرض ۱۵، کمینه ۵ |
|
|
|
|
### شرط آمادگی
|
|
|
|
انتخاب این حالت **برگشتناپذیر** است، پس پیش از ثبت سنجیده میشود: محیط باید حداقل یک
|
|
**منبع فعال** داشته باشد.
|
|
|
|
```json
|
|
{
|
|
"success": false,
|
|
"data": null,
|
|
"errors": [{
|
|
"code": "ERR_VALIDATION_001",
|
|
"message": "برای نوبتدهی منبعمحور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید",
|
|
"field": "booking_mode"
|
|
}]
|
|
}
|
|
```
|
|
|
|
بدون این نگهبان، کلینیک حالتی را برای همیشه قفل میکرد که هیچ وقتی در آن محاسبه نمیشود.
|
|
|
|
پنل همین شرطها را **پیش از** ثبت بهصورت ✓/✗ نشان میدهد تا کاربر به ۴۲۲ نخورد.
|
|
|
|
### `has_segments` روی فهرست سرویسها
|
|
|
|
`GET /api/v1/service-items` حالا فیلد `has_segments` هم میدهد — با یک کوئری تجمعی برای
|
|
کل فهرست، نه یکی per سرویس. تنظیمات نوبتدهی از همین میفهمد آمادگیِ حالت منبعمحور
|
|
هست یا نه.
|
|
|
|
جزئیات بخشها: [appointment-plan.md](appointment-plan.md) · منابع: [resource.md](resource.md)
|