# Appointment API

> **Prefix:** `/api/v1/appointment*`

---

## GET `/api/v1/appointment-slots`

Get all appointment slots (available and booked) for a doctor on a specific date.

**Permission:** `PUBLIC` (anonymous), plus an authenticated **management** mode — see `management` below.

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `date` | string | ✅ | Date in `Y-m-d` format (e.g. `2024-06-15`) |
| `clinic_uuid` | string (UUID) | ❌ | Booking context; omitted = doctor's personal office |
| `management` | `1` | ❌ | Management mode — see note |

> **Management mode (`management=1`).** Turning off online booking (`online_booking_enabled=false`) or the advance booking-window limit are **public-site rules only**. When the request carries a **valid JWT** of a user who may manage this doctor/clinic's appointments (admin, the doctor themself, a clinic manager/secretary with the `appointments` permission), passing `management=1` bypasses those two gates so the panel always shows slots. Past dates are still rejected. If the token is missing or the user is not authorized, `management` is ignored and the endpoint behaves as public (fail-safe). Same flag applies to `/appointment-service-slots`, `/appointment-booking-locations/{doctorUuid}`, and `/appointment-settings/month-availability/{doctorUuid}`.
>
> These four routes moved from the `security: false` firewall onto the JWT firewall so a bearer token can be authenticated on them; anonymous callers still reach them via the `PUBLIC_ACCESS` access-control rules.

### Response `200`
```json
{
  "success": true,
  "data": {
    "doctor_uuid": "550e8400-...",
    "date": "2024-06-15",
    "sessions": [
      {
        "start_time": "09:00",
        "end_time": "13:00",
        "slots": [
          {
            "start": 1718438400,
            "end": 1718439600,
            "start_time": "09:00",
            "end_time": "09:20",
            "location_id": null,
            "is_available": true
          },
          {
            "start": 1718439600,
            "end": 1718440800,
            "start_time": "09:20",
            "end_time": "09:40",
            "location_id": null,
            "is_available": false
          }
        ]
      },
      {
        "start_time": "15:00",
        "end_time": "17:00",
        "slots": [...]
      }
    ]
  }
}
```

> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either taken by a booking **or** its start time has already passed (for today's date). A slot counts as taken when an overlapping appointment is in any *blocking* status (`Appointment::SLOT_BLOCKING_STATUSES`): `confirmed`, `completed`, `following_up`, `salon`, `no_show`, or a still-live `pending` (not yet expired). Only `cancelled_by_user` / `cancelled_by_doctor` / `expired` release the slot. Session boundaries match the doctor's `WeeklySchedule` or date override config.
>
> Returns an **empty** `sessions` array when the date is a holiday, a closed date override, in the past, beyond the doctor's booking window, or when online booking is disabled (see `meta` in `appointment-settings.md`).

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | Missing or invalid date/doctor_uuid |

---

## GET `/api/v1/appointment-service-slots`

زمان‌های خالیِ کافی در **حالت نوبت‌دهی سرویسی** (`booking_mode = service`). برخلاف `/appointment-slots` که اسلاتِ ثابت می‌سازد، این endpoint مدت نوبت را از مجموعِ `duration_minutes` سرویس‌های انتخاب‌شده (+ `buffer_minutes` برنامهٔ هفتگی) می‌گیرد و فضای خالی داخل شیفت‌ها را با رد کردن نوبت‌های اشغال‌شده می‌چیند. فقط سرویس‌های «نمایش در نوبت‌دهی» (`bookable = true`) پذیرفته می‌شوند.

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (uuid) | ✅ | |
| `date` | string `Y-m-d` | ✅ | |
| `service_item_uuids[]` | string[] | ✅ | یک یا چند UUID سرویسِ bookable |
| `durations[<service_uuid>]` | int | ❌ | override مدت (دقیقه) برای همان سرویس — فقط در این محاسبه استفاده می‌شود و مقدار پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند. برای نوبت‌دهیِ منشی که مدت را برای یک نوبت تغییر می‌دهد. مقدار ≤ 0 یا غایب ⇒ مدت پیش‌فرض سرویس |
| `management` | `1` | ❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبت‌دهی آنلاین و سقف بازهٔ رزرو دور زده می‌شود (رجوع به توضیح `/appointment-slots`) |
| `clinic_uuid` | string (uuid) | ❌ | محلِ نوبت‌دهی. غایب = مطب شخصی پزشک |
| `exclude_appointment_uuid` | string (uuid) | ❌ | **ویرایش/جابه‌جایی**: بازهٔ همین نوبت اشغال حساب نشود، وگرنه زمان فعلی‌اش در فهرست نمی‌آید و «همان ساعت، سرویس متفاوت» ناممکن می‌شود. فقط برای کاربری که همان نوبت را مدیریت می‌کند؛ وگرنه `403` |

### Response `200`

خروجی واقعی (شیفت ۰۹:۰۰–۱۲:۰۰، دو سرویس ۲۰+۱۵ دقیقه، بافر ۱۰، با `exclude_appointment_uuid`):

```json
{
  "success": true,
  "data": {
    "doctor_uuid": "f8ad91de-f939-4c72-bb6e-f73b12b9f9e0",
    "date": "2026-08-01",
    "total_duration_minutes": 35,
    "buffer_minutes": 10,
    "clinic_uuid": null,
    "start_times": [
      { "start": 1785562200, "end": 1785564300, "start_time": "09:00", "end_time": "09:35", "location_id": 1 },
      { "start": 1785564900, "end": 1785567000, "start_time": "09:45", "end_time": "10:20", "location_id": 1 },
      { "start": 1785567600, "end": 1785569700, "start_time": "10:30", "end_time": "11:05", "location_id": 1 },
      { "start": 1785570300, "end": 1785572400, "start_time": "11:15", "end_time": "11:50", "location_id": 1 }
    ]
  }
}
```

`start_times` خالی یعنی در آن روز فضای کافی نیست. `end` بدونِ بافر است — بافر فقط فاصلهٔ
بین دو نوبت است، پس **گام کاندیدها `مدت + بافر`** می‌شود: در مثال بالا ۴۵ دقیقه، و
**۱۱:۰۰ پیشنهاد نمی‌شود** حتی اگر آزاد به نظر برسد.

⚠️ هر مسیری که زمان می‌گیرد باید **عضویت در همین فهرست** را بسنجد، نه فقط «اشغال نبودن»:
`isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید، ولی این فهرست شیفت، تعطیلی،
`date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند.

> **این پاسخ مرزِ شیفت‌ها را نمی‌گوید — و کلاینت هم نمی‌تواند حدس بزند.**
> برخلاف `appointment-slots` که `sessions` جدا می‌دهد، اینجا `start_times` مسطح است.
> با فهرست مسطح، شکافِ بین دو شیفت از شکافِ یک نوبتِ اشغال‌شده **قابل تفکیک نیست**:
> گام عادی `مدت + بافر` است و دو نوبت پشت‌سرهم شکافی می‌سازد که از تعطیلیِ میان صبح و
> عصر تشخیص‌پذیر نیست. هر آستانه‌ای که این دو را جدا کند، روی سرویس‌های بلند (گام >
> آستانه) هر اسلات را یک گروه می‌کند و روی نوبت‌های اشغال گروهِ جعلی می‌سازد.
>
> پس `nobat724_front/lib/appointmentSlots.js` عمداً **یک** session با بازهٔ واقعی
> برمی‌گرداند و تفکیک شیفت نمی‌سازد. اگر تفکیک لازم شد، باید **سرور** بدهد — جای
> طبیعی‌اش endpoint حالت چندمنبعی (تسک ۰۶) است، نه تغییر قرارداد این یکی که سه کلاینت
> مصرفش می‌کنند.

**عمومی** (بدون احراز هویت — مصرف‌کننده: سایت nobat724)، مگر با `exclude_appointment_uuid`
که JWT لازم دارد.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404/422 | Doctor / service item not found |
| `ERR_VALIDATION_001` | 422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، مدت سرویس تعریف نشده، سرویس به این محل تعلق ندارد، یا نوبتِ `exclude` مال پزشک دیگری است |
| `ERR_ACCESS_DENIED` | 403 | `exclude_appointment_uuid` داده شد ولی کاربر آن نوبت را مدیریت نمی‌کند |

---

## GET `/api/v1/appointment-booking-services/{doctorUuid}`

**عمومی.** روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی. سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ «انتخاب سرویس» را نشان دهد (حالت `service`) یا جریان اسلاتیِ فعلی (حالت `slot`).

### Response `200`
```json
{
  "success": true,
  "data": {
    "doctor_uuid": "…",
    "booking_mode": "service",
    "buffer_minutes": 5,
    "services": [
      { "uuid": "…", "name": "عصب‌کشی", "duration_minutes": 30, "price_rials": 5000000, "service_section": { "uuid": "…", "name": "دندان" } }
    ]
  }
}
```
`services` فقط سرویس‌های `bookable=true` و فعالِ پزشک را دارد؛ در حالت `slot` معمولاً خالی است.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |

---

## GET `/api/v1/appointment-settings/month-availability/{doctorUuid}`

Which days of a month are bookable — used by the public calendar to grey out unavailable days.

**Permission:** `PUBLIC`

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `doctorUuid` | string (UUID) | Doctor UUID |

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `year` | integer | ✅ | **Gregorian** year (e.g. `2026`) |
| `month` | integer | ✅ | Gregorian month `1`–`12` |
| `clinic_uuid` | string (UUID) | ❌ | Booking context; omitted = personal office |
| `management` | `1` | ❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبت‌دهی آنلاین دور زده می‌شود (رجوع به `/appointment-slots`) |

> Input is Gregorian. A Jalali (Shamsi) front-end must convert the displayed month to the Gregorian month(s) it spans before calling.

### Response `200`
```json
{
  "success": true,
  "data": {
    "year": 2026,
    "month": 6,
    "disabled_dates": ["2026-06-01", "2026-06-17", "2026-06-26"],
    "enabled_dates": ["2026-06-15", "2026-06-16", "2026-06-18"],
    "online_booking_enabled": true,
    "booking_window": { "value": 3, "unit": "month" }
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `disabled_dates` | string[] | `Y-m-d` days with no bookable slot (holiday / closed override / non-working / past / out-of-window) |
| `enabled_dates` | string[] | `Y-m-d` days with at least one slot |
| `online_booking_enabled` | boolean | Doctor's online-booking flag |
| `booking_window` | object | `{ value, unit }` — `unit` is `day`, `week` or `month` (default `3 month`) |

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | Invalid year/month |

---

## POST `/api/v1/appointment`

Book an appointment slot.

**Permission:** `AUTH` — any authenticated user

### Request Body (`application/json`)
```json
{
  "doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "slot_start": 1718438400,
  "slot_end": 1718439600,
  "for_self": false,
  "patient_name": "علی احمدی",
  "patient_mobile": "09120000000",
  "patient_national_code": "0012345678",
  "patient_gender": "man",
  "patient_reason": "چکاپ",
  "note": "لطفاً سریع ویزیت شوم"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه می‌کند؛ در غیر این صورت مقدار کلاینت حفظ می‌شود |
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** می‌شوند (چند سرویس). اولین سرویس به‌عنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422` |
| `duration_from_services` | boolean | ❌ | `true` = حالت نوبت‌دهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیش‌فرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ می‌شود) |
| `service_durations` | object | ❌ | override مدت هر سرویس به‌صورت `{ "<service_uuid>": <minutes> }` — فقط وقتی `duration_from_services=true`. برای نوبت‌دهیِ منشی که مدت را برای همان نوبت تغییر می‌دهد؛ در محاسبهٔ `slot_end` لحاظ می‌شود و **مقدار پیش‌فرضِ سرویس تغییر نمی‌کند**. مقدار ≤ 0 یا غایب ⇒ مدت پیش‌فرض |
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
| `patient_national_code` | string | ✅ | کد ملی بیمار — **همیشه الزامی** (هر دو حالت `for_self`). باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
| `patient_gender` | string | ✅ | جنسیت بیمار — **همیشه الزامی**. ورودی `man`/`male` یا `woman`/`female` پذیرفته می‌شود و به فرمِ متعارف `man`/`woman` ذخیره می‌گردد |
| `patient_reason` | string | ❌ | Reason for visit |
| `note` | string | ❌ | Patient note |
| `city_id` | integer | ❌ | شناسه‌ی شهرِ دامنه‌ی جاری (از `city.json` سایت). برای گاردِ پورسانت نماینده: اگر شهر نماینده‌ی فعال داشته باشد، `booking_representation_id` نوبت ست می‌شود. پورسانت فقط وقتی واریز می‌شود که این نماینده با نماینده‌ی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت |

> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامه‌ی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره می‌کند. در پاسخ به‌صورت `address_id` برمی‌گردد. همه‌ی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست می‌کنند.

> **تضمین عدم رزرو دوگانه:** هر سه مسیر رزرو از `AppointmentRepository::bookAtomically()` عبور می‌کنند و یک قید یکتای دیتابیسی (`active_slot_key`) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر `(doctor, slot_start)` ممکن است و درخواست بازنده `409 SLOT_TAKEN` می‌گیرد. نوبت‌های لغو/منقضی اسلات را آزاد می‌کنند (کلید `NULL`). علاوه بر این، `bookAtomically` داخل تراکنش یک قفلِ per-doctor (`PESSIMISTIC_WRITE` روی ردیف پزشک) می‌گیرد؛ چون در **حالت سرویسی** نوبت‌ها طول متغیر و شروعِ متفاوت دارند و قید یکتای `(doctor, slot_start)` تداخلِ بازه‌ایِ دو رزروِ هم‌زمان با شروعِ متفاوت را نمی‌گیرد. این قفل بررسیِ overlap و insert را نسبت به سایر رزروهای همان پزشک اتمیک می‌کند.

> **Auto-add to clinic:** هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (`DoctorAddress.clinic_id`)، بیمار علاوه بر پرونده‌ی پزشک، به پرونده‌های آن کلینیک هم اضافه می‌شود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه می‌شود. هر شاخه مشروط به فعال‌بودن `patient_records`. جزئیات در `docs/api/patient.md`.
>
> **Auto-fill session on confirm:** پرونده‌ای که هنگام تأیید نوبت خودکار ساخته می‌شود، اکنون از خود نوبت پر می‌شود: `session_at` = زمان واقعی نوبت (`slot_start`)، `visit_price_rials` = هزینه ویزیت نوبت (در نبود آن، «قیمت ویزیت آزاد» تنظیمات)، برای هر سرویسِ نوبت یک ردیف `SessionService` با قیمت snapshot از خود سرویس، و `services_total_rials`/`final_price_rials` محاسبه‌شده. قبلاً همه‌ی این مقادیر صفر/خالی ثبت می‌شدند (باگ).

> **Payer vs patient:** the authenticated user (`user`) is always the payer; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs.

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "appt-uuid-...",
    "doctor": { "uuid": "...", "name": "علی احمدی" },
    "user": { "uuid": "...", "mobile": "..." },
    "slot_start": 1718438400,
    "slot_end": 1718439600,
    "status": "pending",
    "note": "...",
    "expires_at": 1718438100,
    "patient_name": "علی احمدی",
    "patient_mobile": "09120000000",
    "patient_national_code": "0012345678",
    "patient_gender": "man",
    "patient_reason": "چکاپ",
    "version": 1,
    "created_at": 1717000000
  }
}
```

**Appointment Status Values:**
| Value | Description |
|-------|-------------|
| `pending` | Awaiting payment |
| `confirmed` | Paid and confirmed |
| `cancelled` | Cancelled |
| `completed` | Visit completed |
| `no_show` | Patient did not show |

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Slot already booked (incl. concurrent booking — the booking is atomic) |
| `ERR_VALIDATION_001` | 422 | Invalid slot times, past slot, missing patient name/mobile when `for_self=false`, missing/invalid `patient_national_code`, or `patient_gender` not in `man`/`male`/`woman`/`female` |

---

## Single-appointment access model

`GET /appointment/{uuid}`, `PATCH /appointment/{uuid}`, `PATCH /appointment/{uuid}/status`
and `GET /appointment/{uuid}/events` all resolve access through
`App\Appointment\Security\AppointmentAccessChecker`. The decision is driven by the
appointment's own environment (`appointment.clinic`: `null` = the doctor's personal
office, a value = that clinic) — **not** by the caller's role.

| Caller | Allowed |
|---|---|
| `ROLE_ADMIN` | everything |
| Owning doctor (`appointment.doctor.user`) | everything |
| Patient (`appointment.user`) | `view` and `cancel` only — never reschedule/edit |
| Clinic owner | everything, when `appointment.clinic` is their clinic |
| Member doctor of that clinic | per `ClinicDoctorPermission.appointments.{view,cancel,update_status}`; denied once the row is `active = false` |
| Secretary | active-context scope must match the appointment (same clinic **and** an assigned doctor, or the scope doctor), then `DoctorSecretary.appointments.{view,cancel,update_status}` |

Actions map onto the existing permission vocabulary: reads use `view`; edit / move /
reserve-transfer / replace / non-cancel status changes use `update_status`; any
transition to `cancelled_by_doctor` / `cancelled_by_user` requires `cancel` — including
an inline `status` sent to `PATCH /appointment/{uuid}`. Denials return
`ERR_ACCESS_DENIED` with HTTP `403`.

> Deactivating a doctor in a clinic (`ClinicDoctorPermission.active = false`) or a
> secretary (`DoctorSecretary.active = false`) is the single source of truth for
> "collaboration ended" — both checkers refuse on it. The clinic owner keeps full access.

---

## GET `/api/v1/appointment/{uuid}`

Get appointment detail.

**Permission:** `AUTH` — see [Single-appointment access model](#single-appointment-access-model)

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Appointment UUID |

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "appt-uuid-...",
    "doctor": {
      "uuid": "...",
      "name": "علی احمدی",
      "specialties": [
        { "uuid": "...", "name": "اورولوژی عمومی" }
      ]
    },
    "address": {
      "uuid": "...",
      "name": "مطب دکتر علی احمدی",
      "address": "یزد، خیابان ...",
      "telephone": "035...",
      "map": { "latitude": "31.8", "longitude": "54.3" },
      "city": { "id": "132", "name": "یزد" },
      "province": { "id": "100", "name": "یزد" }
    },
    "user": { "uuid": "...", "mobile": "09..." },
    "slot_start": 1718438400,
    "slot_end": 1718439600,
    "status": "confirmed",
    "note": "...",
    "patient_name": "...",
    "patient_mobile": "...",
    "insurance_service_category": "inpatient",
    "insurance_service_category_label": "خدمات بستری",
    "insurance_base_id": 3,
    "created_at": 1717000000
  }
}
```
> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` اولین آدرس پزشک است (ممکن است `null` اگر پزشک آدرسی ندارد). `address.map.latitude/longitude` رشته یا `null`. تاریخ‌ها Unix.

### انتخاب بیمهٔ نوبت

| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_service_category` | string \| null | نوع خدمتِ بیمه‌ایِ این نوبت — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). `null` = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (`default_service_category` در [insurance.md](insurance.md)) |
| `insurance_service_category_label` | string \| null | برچسب فارسی همان نوع |
| `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد |
| `insurance_supplementary_id` | int \| null | بیمهٔ **تکمیلیِ** انتخاب‌شده؛ قرارداد فعال لازم دارد و روی **باقیماندهٔ بعد از بیمهٔ پایه** محاسبه می‌شود ([فرمول زنجیره‌ای](billing.md)) |

نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Caller fails the single-appointment access model |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |

---

## GET `/api/v1/appointments/doctor/{doctorUuid}`

Get all appointments for a specific doctor.

**Permission:** `AUTH` — the doctor themselves or `ROLE_ADMIN` see every appointment of
that doctor. A clinic user (owner, or member doctor holding `appointments.view`) may also
call it, but the result is **scoped to their own clinic**: only appointments whose
`clinic_id` is that clinic are returned, so the doctor's personal-office appointments
never leak into a clinic. Anyone else gets `403`.

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `doctorUuid` | string (UUID) | Doctor UUID |

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `status` | string | ❌ | Single-status filter (legacy) |
| `statuses` | string[] | ❌ | Repeatable: `statuses=pending&statuses=confirmed` |
| `from` | int | ❌ | Unix ts — `slot_start >= from` |
| `to` | int | ❌ | Unix ts — `slot_start <= to` |
| `q` | string | ❌ | Substring match on patient name / mobile (appointment *and* user fields) |
| `service_uuid` | string (UUID) | ❌ | Filter by service item |
| `page` | int | ❌ | Default `1` |
| `limit` | int | ❌ | Default `20`, max `100` |

**Two response shapes.** With **none** of `statuses`/`from`/`to`/`q`/`service_uuid`/`page`/`limit`
present, the legacy nested-array response below is returned unchanged. With **any** of them
present the response is the standard paginated envelope
(`{ success, data: [...], meta: { totalRecords, totalPages, currentPage, limit } }`).
The doctor dashboard filter bar uses the paginated form, defaulting `statuses` to
`pending` + `confirmed` (i.e. "not yet visited").

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "user": { "uuid": "...", "real_name": "..." },
      "slot_start": 1718438400,
      "slot_end": 1718439600,
      "status": "confirmed",
      "price": 500000
    }
  ]
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Not the doctor/admin, and no clinic scope granting `appointments.view` over this doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |

---

## GET `/api/v1/appointments/user`

Get all appointments for the authenticated user.

**Permission:** `AUTH`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `status` | string | ❌ | Filter by status |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "doctor": { "uuid": "...", "title": "علی احمدی" },
      "slot_start": 1718438400,
      "slot_end": 1718439600,
      "status": "confirmed",
      "price": 500000
    }
  ]
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |

---

## PATCH `/api/v1/appointment/{uuid}/status`

Change appointment status.

**Permission:** `AUTH` — see [Single-appointment access model](#single-appointment-access-model).
The required action depends on the target status: a transition to `cancelled_by_doctor` /
`cancelled_by_user` needs `appointments.cancel`, everything else needs
`appointments.update_status`. A clinic secretary therefore confirms and completes by
default but cannot cancel until `cancel` is granted.

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Appointment UUID |

### Request Body
```json
{
  "status": "cancelled_by_doctor",
  "version": 3,
  "cancel_reason": "بیمار درخواست لغو داد"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `status` | string | ✅ | New status value |
| `version` | integer | ❌ | Optimistic lock version (prevents double-submit) |
| `cancel_reason` | string | ❌ | Only when transitioning to `cancelled_by_doctor` / `cancelled_by_user`. Stored on the recorded cancellation event (Timeline). Ignored for other statuses. |

**Allowed Transitions by Actor:** the state machine itself is
`Appointment::ALLOWED_TRANSITIONS` (identical for everyone); the actor only decides
*whether* the transition may be attempted:

| Actor | Allowed |
|-------|---------|
| Patient | cancellation of their own appointment only |
| Doctor (owner) / clinic owner / admin | any transition the state machine permits |
| Member doctor / secretary | non-cancel transitions with `update_status`; cancellations only with `cancel` |

> **Cancellation is logged.** When the status becomes `cancelled_by_doctor` or `cancelled_by_user`, an `AppointmentEvent` (type `cancelled`, title «نوبت لغو شد») is recorded with the actor (user id + name), the optional `cancel_reason`, and the cancel time — surfaced via `GET /api/v1/appointment/{uuid}/events`. A `warning`-level entry is also written to `app_log`.

### Response `200`
Updated appointment object.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Caller lacks `update_status` (or `cancel` for a cancellation) on this appointment |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Invalid status value |

---

## POST `/api/v1/appointment/{uuid}/confirm`

Confirm an appointment («ثبت شده» → «قطعی شده») and register its money on the patient
case file — status transition, case file / visit, and payments in **one atomic
transaction**. If any step fails nothing is committed.

**Permission:** `AUTH` — `appointments.update_status` per the
[single-appointment access model](#single-appointment-access-model).

### Request Body
```json
{
  "version": 3,
  "insurance_service_category": "inpatient",
  "insurance_base_id": 3,
  "payments": [
    { "method": "pos",  "amount_rials": 3000000, "payment_method_uuid": "…pos-uuid…", "reference": "TRX-42" },
    { "method": "cash", "amount_rials": 2000000 }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. |
| `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. |
| `insurance_supplementary_id` | integer | ❌ | بیمهٔ تکمیلی، همان قواعد `PATCH`. روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شود. |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). |
| `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` |
| `payments[].amount_rials` | integer | ✅ | > 0 |
| `payments[].payment_method_uuid` | string | ❌ | uuid of a registered POS device (`pos`) or bank account (`card`) from `/api/v1/my/payment-methods/*`. Stored as-is (max 36). |
| `payments[].reference` | string | ❌ | Transaction / tracking id (max 255). |

Each stored payment keeps its `method`, `amount_rials`, `payment_method_uuid`, `reference`,
and `paid_at` (see the `session_payments[]` in the visit response).

The sum of `payments` may not exceed the visit's payable amount → `ERR_SESSION_PAYMENT_EXCEEDS`.
Partial payment is normal: the remainder stays as `remaining_rials` on the visit and can be
collected later through `POST /api/v1/session/{uuid}/payments`.

> **نوبت آنلاین با پرداخت موفق، خودبه‌خود قطعی نمی‌شود.** پرداخت فقط پنجرهٔ انقضای درگاه
> را برمی‌دارد (`expires_at = null`) و نوبت در وضعیت `pending` («ثبت شده») می‌ماند تا پزشک/منشی
> از همین اندپوینت آن را قطعی کند. ساخت پرونده/مراجعه و تقسیم مالی (پورسانت نماینده و سهم منشی)
> هم در همین لحظهٔ تأیید انجام می‌شود، نه لحظهٔ پرداخت.

### What happens on the server
1. انتخاب بیمه (اگر در بدنه آمده باشد) روی نوبت می‌نشیند و اعتبارسنجی می‌شود.
2. `pending → confirmed` (state machine still applies).
3. `AppointmentConfirmationService` files the case file for the appointment's environment
   (`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing**
   record for that patient in that environment is reused, otherwise a new one is created.
   A `PatientSession` is opened with the visit price and one line per attached service.
4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از
   نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve
   ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). محاسبه زنجیره‌ای
   است: پایه روی کل، تکمیلی روی باقیمانده. نوبتِ بدون بیمه مثل قبل کاملاً سهم بیمار می‌ماند.
   مراجعهٔ بیمه‌دار همین‌جا صورتحساب نهایی و مطالبهٔ بیمه هم می‌گیرد
   (`SessionBillingService` → رویداد `InvoiceFinalized`؛ [billing.md](billing.md)).
5. Each payment row is registered on that visit (`wallet` also debits the patient wallet).

> پیش از این، پرونده‌ای که با قطعی‌کردن ساخته می‌شد همیشه کل مبلغ را سهم بیمار می‌گذاشت
> (`applyShares($gross, 0, 0, $gross)`) و صفحهٔ پرداخت با فاکتور واگرا می‌شد.

پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`،
`supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`،
`insurance_base_id`، `insurance_supplementary_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است.

### Response `200`
```json
{
  "success": true,
  "data": {
    "appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
    "session": {
      "uuid": "…",
      "visit_price_rials": 5950000,
      "services_total_rials": 0,
      "final_price_rials": 833000,
      "discount_rials": 0,
      "paid_total_rials": 0,
      "remaining_rials": 833000,
      "is_paid": false,
      "insurance_service_category": "outpatient",
      "insurance_base_id": 176,
      "insurance_supplementary_id": 182,
      "gross_total_rials": 5950000,
      "base_insurance_rials": 1785000,
      "supplementary_insurance_rials": 3332000,
      "patient_share_rials": 833000
    }
  }
}
```

> نمونهٔ بالا خروجی واقعیِ همان مسیر است: ویزیت ۵٬۹۵۰٬۰۰۰ · پایه ۳۰٪ سرپایی → ۱٬۷۸۵٬۰۰۰ ·
> تکمیلیِ ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۴٬۱۶۵٬۰۰۰ → ۳٬۳۳۲٬۰۰۰ · سهم بیمار ۸۳۳٬۰۰۰.

`session` is `null` when the tenant does not have the `patient_records` subscription
feature — the appointment is still confirmed, it simply has no case file. **Sending
`payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms
nothing**, because there would be nowhere to record the money.

Reserve-list entries (`is_reserve: true`) never open a visit; move them onto a real slot
first.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | No `update_status` on this appointment |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Payments sent but the tenant has no `patient_records` feature |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Transition to `confirmed` not allowed from the current status |
| `ERR_SESSION_PAYMENT_INVALID` | 422 | Unknown `method` or non-positive `amount_rials` |
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | Payments exceed the payable amount |

> **Admin panel:** this is the only path to «قطعی شده». Picking `confirmed` in
> `AppointmentStatusDropdown` opens the «قطعی کردن نوبت» modal rather than issuing a raw
> `PATCH .../status`, so confirmation can never silently skip the case file and payment.

---

## GET `/api/v1/appointment/{uuid}/events`

Appointment Timeline — chronological event history for one appointment. Currently records cancellation events; the structure is generic for future event types.

**Permission:** `IS_AUTHENTICATED_FULLY` — read-only, so it needs `view` (not `update_status`):
see [Single-appointment access model](#single-appointment-access-model). The patient sees
their own Timeline.

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Appointment UUID |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "type": "cancelled",
      "title": "نوبت لغو شد",
      "actor_name": "دکتر حامد حسینی",
      "reason": "بیمار درخواست لغو داد",
      "created_at": 1784273931
    }
  ]
}
```
Events are ordered oldest → newest. `data` is a flat array (single nesting). `actor_name` and `reason` may be `null`. `created_at` is a Unix timestamp.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | Not allowed to view this appointment |
| `ERR_VALIDATION_002` | 404 | Appointment not found |

---

## POST `/api/v1/my/appointment`

Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.

> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
> online, quick, or regular — starts as registered; confirming it is a separate act that
> shows the costs and takes payment (`POST /api/v1/appointment/{uuid}/confirm`). Because
> of that, **no case file / visit is opened at creation time** any more; it is opened on
> confirmation.
>
> A panel-created `pending` appointment still **occupies its slot** (so the time stays
> reserved) and carries **no `expires_at`**, so it is never auto-expired: only online
> gateway holds (created with a 15-minute TTL by `POST /api/v1/appointment`) are swept by
> `AppointmentExpiryService`. Ending a stale registered appointment is an operator
> decision (cancel).

**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`

> **Scope enforced:** the caller must be related to the target `doctor_uuid`, not merely hold an allowed role. A doctor may book only onto their own calendar; a clinic only onto doctors that belong to it; a secretary only within their active clinic/doctor scope **and** with the `appointments.create` permission; admin onto any. Otherwise `403 FORBIDDEN`.

### Request Body
```json
{
  "doctor_uuid": "doctor-uuid",
  "slot_start": 1718438400,
  "slot_end": 1718439600,
  "patient_mobile": "09123456789",
  "patient_name": "علی محمدی",
  "patient_national_code": "0012345678",
  "note": "optional note"
}
```

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `patient_mobile` | string | ✅ | راه تماس بیمار |
| `patient_name` | string | ✅ | نام بیمار — فقط برای بیمارِ **کاملاً جدید** استفاده می‌شود؛ اگر کد ملی به پروفایلِ موجود بخورد، نامِ همان پروفایل روی نوبت ذخیره و نمایش داده می‌شود و این ورودی نادیده گرفته می‌شود |
| `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
| `visit_price_rials` | int | شرطی | هزینه ویزیت (ریال). اختیاری؛ ولی اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. روی نوبت ذخیره و در `toArray` با کلید `visit_price_rials` برمی‌گردد |

> **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود.
>
> **نامِ نمایش‌داده‌شده‌ی بیمار:** چون بیمار با کد ملی به پروفایل واقعی‌اش resolve می‌شود، snapshotِ نامِ نوبت (`patient_name`) از **نامِ همان پروفایل** (`User.realName`) پر می‌شود، نه از نامِ تایپ‌شده در مودال. نامِ ورودی فقط وقتی روی نوبت می‌نشیند که بیمار کاملاً جدید باشد و نامی نداشته باشد. لیستِ `GET /api/v1/my/appointments` هم همین را نشان می‌دهد (`override_name` تنها برای رزروِ عمومیِ «برای شخص دیگر» — که `user` صاحب حساب است — از `realName` جدا می‌شود).
>
> **وضعیت نوبتِ ساخته‌شده:** این endpoint نوبت را همیشه `pending` می‌سازد. صفحهٔ «افزودن نوبت» پنل (`/admin/appointments/new`) نوبتِ **قطعی** می‌سازد، پس بلافاصله پس از ساخت، خودش `POST /api/v1/appointment/{uuid}/confirm` را با `payments: []` صدا می‌زند. اگر آن مرحله شکست بخورد، نوبت `pending` می‌ماند (اسلات همچنان اشغال است) و به کاربر گفته می‌شود از لیست نوبت‌ها قطعی کند.
>
> **انتخاب زمان در پنل:** در حالت نوبت‌دهی **اسلاتی**، صفحهٔ افزودن نوبت زمان را از `GET /api/v1/appointment-slots` می‌گیرد و فقط اسلاتِ `is_available` قابل انتخاب است؛ ورود دستیِ ساعت فقط به‌عنوان «ثبت خارج از برنامه» باقی مانده (مثلاً روزی که پزشک برنامهٔ کاری ندارد). در حالت **سرویسی**، زمان‌ها از `GET /api/v1/appointment-service-slots` می‌آیند.

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "appt-uuid",
    "slot_start": 1718438400,
    "slot_end": 1718439600,
    "status": "pending"
  }
}
```

### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `FORBIDDEN` | 403 | Role not allowed, or caller not scoped to this doctor |
| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) |
| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
| `SLOT_TAKEN` | 409 | Slot already booked |

---

## GET `/api/v1/my/appointment/patient-lookup`

جستجوی بیمار با شماره موبایل **یا** کد ملی، پیش از ثبت نوبت. فرم ثبت نوبت با یکی از این دو معیار جستجو می‌کند؛ اگر بیمار یافت شد و کد ملی دارد، مستقیم استفاده می‌شود، وگرنه بقیهٔ مشخصات (نام و موبایل یا کد ملی) از کاربر گرفته می‌شود.

**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`

> برخلاف `GET /api/v1/patient/search-user`، این endpoint به فیچر `patient_records` اشتراک وابسته نیست و `ROLE_ADMIN` را هم می‌پذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.

### Query Parameters
یکی از `mobile` یا `national_code` الزامی است. اگر هر دو ارسال شوند، `national_code` اولویت دارد.

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile` | string | یکی از دو | شماره موبایل ایران (`^09\d{9}$`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
| `national_code` | string | یکی از دو | کد ملی ۱۰ رقمی (`^\d{10}$`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |

### Response `200` — یافت شد
```json
{
  "success": true,
  "data": {
    "found": true,
    "name": "علی محمدی",
    "mobile": "09123456789",
    "national_code": "0012345678"
  }
}
```
> `national_code` ممکن است `null` باشد (بیمار قدیمی بدون کد ملی) — در این حالت فرم کد ملی را می‌گیرد.

### Response `200` — یافت نشد
```json
{ "success": true, "data": { "found": false } }
```

### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `FORBIDDEN` | 403 | Role not allowed |
| `VALIDATION` | 422 | Invalid `national_code` (`field: national_code`)، یا هیچ‌کدام از `mobile`/`national_code` معتبر نبود (`field: mobile`) |

---

## GET /api/v1/my/appointments

Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.

**Auth:** `IS_AUTHENTICATED_FULLY` (any role)

**Role behavior:**
| Role | Scope |
|------|-------|
| `ROLE_ADMIN` | All appointments |
| `ROLE_CLINIC` | Appointments for doctors in this clinic |
| `ROLE_DOCTOR` | Appointments for this doctor |
| `ROLE_SECRETARY` | Appointments of the doctors assigned to this secretary in their active scope (empty if `appointments.view` is false) |
| (plain patient `ROLE_USER`) | The patient's own appointments (`a.user = current user`) |

### GET `/api/v1/my/appointments/today-stats`

Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).

**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.

### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `limit` | int | 15 | Items per page (max 100) |
| `search` | string | — | Search by mobile, real name, or doctor name |
| `status` | string | — | Filter by appointment status |
| `date` | string | — | Filter by date in `Y-m-d` format |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "string",
      "patient_name": "string",
      "patient_mobile": "string",
      "doctor_name": "string",
      "clinic_name": "string | null",
      "appointment_date": "2026-07-25",
      "appointment_time": "14:30",
      "slot_start": 1700000000,
      "status": "reserved",
      "amount": 0,
      "created_at": "ISO 8601 string"
    }
  ],
  "meta": {
    "totalRecords": 8000,
    "totalPages": 533,
    "currentPage": 1
  }
}

---

## Clinic workflow extensions (نوبت‌ها — Figma)

New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویس‌های نوبت — چند سرویس، هر عضو `{uuid, name, price_rials}`؛ `price_rials` افزوده شد تا مودالِ «قطعی کردن نوبت» بتواند هزینه‌ها را پیش از ساخته‌شدنِ مراجعه نشان دهد), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو — day-level, never occupies a slot).

New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show`

**Domain event on completion.** Reaching `completed` — through either `PATCH /appointment/{uuid}/status` or the general `PATCH /appointment/{uuid}` — records an `AppointmentCompleted` domain event in the outbox (see [domain-events.md](../architecture/domain-events.md)). It is recorded **after** the row is saved, so a rejected transition or a version conflict leaves no event; otherwise the completed count would run ahead of the appointments themselves.

### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) — the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).

```json
{
  "slot_start": 1731000000, "slot_end": 1731001800,
  "is_reserve": false,
  "service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…",
  "service_item_uuids": ["…", "…"], "durations": { "<service_uuid>": 25 },
  "deposit_required": true, "deposit_amount_rials": 5000000,
  "note": "…", "patient_name": "…", "patient_mobile": "…",
  "insurance_service_category": "inpatient", "insurance_base_id": 3,
  "status": "confirmed", "version": 3
}
```

- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.

#### حالت نوبت‌دهی سرویسی (2026-07)

در محلی که `booking_mode = service` است، **مدت داده است نه ورودی**:

- مدت مجاز از سرویس‌های نوبت (یا `service_item_uuids[]` ارسالی) حساب می‌شود و
  `slot_end` باید دقیقاً `slot_start + مدت` باشد. ناسازگاری →
  `422 ERR_APPOINTMENT_003` با فیلد `slot_end` و **عدد درست در پیام**:

  ```json
  {
    "success": false,
    "data": null,
    "errors": [
      { "code": "ERR_APPOINTMENT_003", "message": "مدت این نوبت باید 35 دقیقه باشد", "field": "slot_end" }
    ]
  }
  ```

- `service_item_uuids[]` فهرست را **کامل جایگزین** می‌کند و `service_item` تکی خودکار با
  عضو اول هم‌گام می‌شود. وقتی این کلید بیاید، `service_item_uuid` تکی **نادیده** گرفته
  می‌شود — دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد.
- `durations` override منشی per سرویس، فقط برای همین محاسبه.
- سرویسِ **موجودِ** نوبت اگر غیرفعال شده باشد مانع نمی‌شود (نوبت نباید برای همیشه قفل
  شود)؛ ولی **افزودن** سرویس غیرفعال تازه → `422`.
- `service_total_minutes` / `service_buffer_minutes` روی نوبت ثبت می‌شوند و در پاسخ
  می‌آیند. در حالت اسلاتی `null` می‌مانند.
- **نوبت رزرو معاف است** (`slot_start == slot_end`): سرویس‌ها و مدت ذخیره می‌شوند ولی
  مدت سنجیده نمی‌شود.
- **تبدیل رزرو به نوبت زمان‌دار** با همین endpoint انجام می‌شود:
  `{ "is_reserve": false, "slot_start": …, "slot_end": … }`. endpoint جدایی وجود ندارد و
  لازم نیست — `rescheduleTo()` خودش `active_slot_key` را بازتولید می‌کند.

⛔ در حالت اسلاتی هیچ‌کدام از این بررسی‌ها اجرا نمی‌شود؛ رفتار بیت‌به‌بیت همان قبل است.
رجوع: [docs/architecture/booking-modes.md](../architecture/booking-modes.md)
- `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`.
- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال → `422 ERR_VALIDATION_001` («این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد»)؛ فرستادن بیمهٔ تکمیلی در این فیلد → `422` («اینجا فقط بیمهٔ پایه قابل انتخاب است»)، هر دو با فیلد `insurance_base_id`.
- `insurance_supplementary_id` — همان قواعد، برعکس: فقط قرارداد فعالِ **تکمیلی** پذیرفته می‌شود؛ فرستادن بیمهٔ پایه → `422` («اینجا فقط بیمهٔ تکمیلی قابل انتخاب است») با فیلد `insurance_supplementary_id`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version` → `409` on concurrent edit.

Response `200`: `{ success, data: { data: <appointment.toArray()> } }`

| HTTP | Description |
|------|-------------|
| 404 | نوبت یافت نشد |
| 403 | `ERR_ACCESS_DENIED` — no `update_status` on this appointment, or an inline cancellation without `cancel` |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition, `ERR_APPOINTMENT_003` (مدت با سرویس‌ها نمی‌خواند) |
| 409 | slot taken or version conflict |

### POST `/api/v1/appointment/{uuid}/service-reschedule`

جابه‌جایی سرویس‌آگاه — **فقط حالت `service`**. کلاینت **مدت نمی‌فرستد**: زمان شروع می‌دهد و
سرور مدت را از سرویس‌های نوبت حساب می‌کند. تفاوتش با `PATCH` این است که آنجا کلاینت باید
`slot_end` درست را از قبل بداند؛ همین است که ورودی دستیِ ساعت را از فرم ویرایش حذف می‌کند.

**Permission:** همان مدل دسترسیِ تک‌نوبت (`canManage`).

```json
{
  "start": 1785567600,
  "service_item_uuids": ["…", "…"],
  "durations": { "<service_uuid>": 25 },
  "version": 7
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `start` | int (unix) | ✅ | باید **عضو فهرست** `appointment-service-slots` باشد، نه فقط آزاد |
| `service_item_uuids[]` | string[] | ❌ | غایب = همان سرویس‌های فعلی نوبت |
| `durations` | object | ❌ | override منشی per سرویس |
| `version` | int | ❌ | optimistic lock؛ غایب = بدون قفل |

#### Response `200`

خروجی واقعی:

```json
{
  "success": true,
  "data": {
    "uuid": "02854a61-e9fc-41ae-98c5-fcbcf98a05af",
    "slot_start": 1785567600,
    "slot_end": 1785569700,
    "total_duration_minutes": 35,
    "buffer_minutes": 10,
    "warnings": []
  }
}
```

`warnings[]` پیام‌های فارسیِ غیرمانع است — مثلاً «سرویس «…» دیگر برای نوبت‌دهی فعال نیست».

#### Errors

`start`ی که در فهرست پیشنهادی نیست (مثلاً بیرون شیفت):

```json
{
  "success": false,
  "data": null,
  "errors": [
    { "code": "ERR_APPOINTMENT_001", "message": "این زمان برای مدت انتخابی در دسترس نیست", "field": "start" }
  ]
}
```

| Code | HTTP | Description |
|------|------|-------------|
| — | 404 | نوبت یافت نشد |
| `ERR_ACCESS_DENIED` | 403 | کاربر این نوبت را مدیریت نمی‌کند |
| `ERR_APPOINTMENT_004` | 422 | محل در حالت سرویسی نیست، یا نوبت **رزرو** است (برای رزرو از `PATCH` با `is_reserve: false` استفاده کنید) |
| `ERR_APPOINTMENT_001` | 422 | `start` عضو فهرست زمان‌های پیشنهادی نیست |
| `ERR_VALIDATION_002` | 422 | `start` غایب، یا نوبت هیچ سرویسی ندارد |
| `ERR_VALIDATION_001` | 422 | زمان در گذشته، سرویس بیگانه، سرویس غیرفعالِ تازه، یا مدت تعریف‌نشده |
| `ERR_CONFLICT_001` | 409 | نسخهٔ کهنه، یا بازه هم‌زمان توسط دیگری گرفته شد |

> **`forManagement` از `canManageContext()` می‌آید، نه `canManage()`.** بیمارِ صاحب نوبت
> می‌تواند جابه‌جا کند ولی باید پنجرهٔ رزرو عمومی را رعایت کند؛ پزشک/منشی معاف‌اند.

### POST `/api/v1/my/appointment` (extended)
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `visit_price_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`.
`deposit_amount_rials` **ریال** است (مثل بقیه فیلدهای `_rials`)؛ UI ادمین تومان می‌گیرد و با `tomanToRial` تبدیل می‌کند. داده‌های قدیمی که تومانِ خام ذخیره شده بودند با migration `Version20260717093000` ×۱۰ اصلاح شدند.
`is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`.
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`.
`duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود.
`service_durations` (فقط با `duration_from_services=true`): override مدت هر سرویس `{ "<uuid>": <minutes> }` برای همان نوبت (منشی)؛ در `slot_end` لحاظ می‌شود و مقدار پیش‌فرضِ سرویس تغییر نمی‌کند.

> **بخشِ سرویس در `appointment-booking-services`:** هر آیتم `services[]` علاوه بر `uuid/name/duration_minutes/price_rials`، فیلد `service_section: { uuid, name }` هم دارد تا فرمِ نوبت‌دهیِ سرویسی سرویس‌ها را «بخش → سرویس» گروه‌بندی کند. عقب‌رو-سازگار (افزودنِ فیلد).

### GET `/api/v1/my/appointments` (extended)
New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null).

**افزوده‌های حالت سرویسی (2026-07).** این endpoint سریالایزر خودش دارد (array hydration)،
نه `Appointment::toArray()` — پس فیلدهای زیر صریحاً همان‌جا اضافه شده‌اند:

| فیلد | نوع | توضیح |
|---|---|---|
| `service_items` | array | فهرست **کامل** سرویس‌ها، هر عضو `{uuid, name, price_rials}`. آرایهٔ خالی وقتی سرویسی نیست (نه `null`). `service_item` تکی فقط عضو اول است و کلاینتی که تنها آن را بخواند بقیه را نشان نمی‌دهد |
| `clinic_uuid` | string\|null | محلِ نوبت. `null` = مطب شخصی. کلاینت با این تشخیص می‌دهد روش نوبت‌دهی را از کدام برنامه بپرسد |
| `service_total_minutes` | int\|null | مدت ثبت‌شده؛ در حالت اسلاتی `null` |
| `service_buffer_minutes` | int\|null | بافر مؤثر لحظهٔ ثبت |

`service_items` با یک کوئری جدا برای کل صفحه گرفته می‌شود، نه JOIN به کوئری اصلی: JOIN
روی collection ردیف‌ها را ضرب می‌کند و صفحه‌بندی را می‌شکند (نوبتی با سه سرویس، سه ردیف
می‌شد). N+1 هم نیست.


---

## Booking context (2026-07)

A doctor may now hold several booking schedules — one for the personal practice and one per clinic.
Every public booking endpoint therefore accepts an optional **`clinic_uuid`**:

| Endpoint | Where |
|---|---|
| `GET /api/v1/appointment-slots` | query |
| `GET /api/v1/appointment-service-slots` | query |
| `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
`clinic_uuid` so a client can tell which context answered.

On `POST /api/v1/appointment`, any `service_item_uuids` must belong to the same context, otherwise
`422 ERR_VALIDATION_001` («سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»). The appointment's
`address_id` is resolved from that context's schedule.

**The context is stored on the row.** All three booking paths persist it as
`appointments.clinic_id` (`NULL` = personal practice). Downstream consumers — above all the
automatic case-file creation documented in [patient.md](patient.md#auto-creation-on-appointment-confirm) —
read that column instead of inferring the clinic from `address_id`. The old inference had a
fallback of "the doctor's only clinic", which silently filed appointments under the wrong practice
once per-context schedules existed.

### وضعیت اولیهٔ نوبت

| مسیر | وضعیت هنگام ثبت |
|---|---|
| `POST /api/v1/appointment` (سایت عمومی) | `pending` با TTL پرداخت (`Appointment::PAYMENT_TTL` = ۱۵ دقیقه)؛ با پرداخت موفق `confirmed` می‌شود |
| `POST /api/v1/my/appointment` (پنل) | مستقیم `confirmed` |
| admin booking | مستقیم `confirmed` |

نوبتی که خودِ کلینیک/پزشک ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛ `pending` ماندنش
یعنی نه در تقویم درست شمرده می‌شود و نه پرونده می‌سازد.

> **Silent-failure warning:** before this change the location was inferred from the doctor's single
> schedule. A client that does not send `clinic_uuid` will now book into the personal practice —
> which is correct, but is a behaviour change for any doctor who also works in a clinic. Update
> callers before relying on the default.

### GET `/api/v1/appointment-booking-locations/{doctorUuid}`

**Permission:** public (anonymous via the `PUBLIC_ACCESS` `access_control` entry). This route runs on
the JWT firewall, so a valid bearer + `management=1` enables management mode (`next_available_at` /
`available_on_date` ignore the online-booking toggle) — see the note under `/appointment-slots`.

**Query:** `date` (optional, `Y-m-d`), `management` (optional, `1`).

Lists every place the doctor can be booked at. The site should show **all** of them, grouped by
location — picking one and hiding the rest removes real capacity from the doctor.

```json
{
  "success": true,
  "data": {
    "doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
    "booking_locations": [
      {
        "location_uuid": "0f0b…",
        "type": "personal",
        "title": "مطب شخصی",
        "address": "یزد، خیابان …",
        "clinic_uuid": null,
        "booking_mode": "slot",
        "buffer_minutes": 0,
        "opening_hours": [
          { "day": "Saturday", "opens": "09:00", "closes": "13:00" }
        ],
        "services": [],
        "next_available_at": 1755000000
      },
      {
        "location_uuid": "7c21…",
        "type": "clinic",
        "title": "کلینیک علی بهروزی",
        "address": "یزد، بلوار …",
        "clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
        "booking_mode": "service",
        "buffer_minutes": 10,
        "opening_hours": [
          { "day": "Saturday", "opens": "09:00", "closes": "13:00" },
          { "day": "Sunday",   "opens": "16:00", "closes": "20:00" }
        ],
        "services": [
          { "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
            "service_section": { "uuid": "…", "name": "عمومی" } }
        ],
        "next_available_at": 1754900000
      }
    ]
  }
}
```

| Field | Type | Notes |
|---|---|---|
| `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; 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 |

`next_available_at` is resolved by `SlotCalculatorService::findNextAvailableStart()`, which fetches
the schedule, holidays, overrides and taken appointments once per location and walks the days in
memory. It counts a reserve appointment as blocking, matching `isSlotTaken()` — the looser
`findBusyIntervals()` used by service-mode slot generation would report such a slot as free.

Sorted by `next_available_at` ascending, so `booking_locations[0]` is the sensible default
selection; locations with no capacity sort last. Deep links should carry the chosen location
(`/doctor/{uuid}?location={location_uuid}`).

**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`.

A related legacy issue is the *shape* of the stored setting: very old rows are a bare JSON list
(`[{"sessions": …}]`) covering only Saturday, so every other weekday reads as day-off and the
`meta` block is missing. `php bin/console app:schedule:normalize-format` reports such rows;
`--fix` rewrites them to the canonical `{"0"…"6", "meta"}` shape without touching the session
data (absent days become `{"sessions": []}`, absent meta becomes the defaults).

#### `?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
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`.

---

## GET /api/v1/my/clinic-doctors

**Permission:** `IS_AUTHENTICATED_FULLY`

پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تب‌ها/تایم‌لاینِ صفحهٔ نوبت‌ها. برخلاف
`GET /api/v1/clinic/doctor-list/{clinicUuid}` که روی firewallِ عمومی است و **همهٔ** پزشکانِ
کلینیک را برمی‌گرداند، این اندپوینت احرازشده است و نتیجه را بر اساس نقش محدود می‌کند:

- **منشیِ محیطِ کلینیک** → فقط پزشکانِ **تخصیص‌یافته** به همان منشی (`DoctorSecretary` فعال).
- **منشیِ محیطِ مطب** → همان یک پزشک.
- **کلینیک** → همهٔ پزشکانِ کلینیک · **پزشک** → خودش.

بدونِ این، منشیِ کلینیک پزشکی را در تب می‌دید که برایش مجوزِ نوبت نداشت و روی
slot/booking، `403` می‌گرفت.

**Response `200`:**

```json
{ "success": true, "data": { "data": [ { "uuid": "…", "name": "دکتر …" } ] } }
```
