Files
clinicpro/docs/api/appointment.md
T
hamedandClaude Opus 5 12c1d2cbf4 fix(appointment): fall back to the resource's supervising doctor on public booking
A laser device is not a doctor, so booking one from the public site sent
resource_uuid and no doctor_uuid and got back "doctor_uuid یا resource_uuid
الزامی است" — a message telling the caller to send something it had already
sent. The panel path had resolved this from ClinicResource.supervisor since it
was written; only the public path had not, and the field was defined but never
read there.

A resource with no supervisor now gets its own message pointing at the actual
fix, instead of the generic one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:31:21 +03:30

80 KiB
Raw Blame History

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

{
  "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):

{
  "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

{
  "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 112
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

{
  "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)

{
  "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). با service_item_uuids نادیده گرفته می‌شود و سرور خودش حساب می‌کند (به مقدار کلاینت اعتماد نمی‌شود)؛ در آن حالت الزامی هم نیست. بدون سرویس، مقدار کلاینت حفظ می‌شود و الزامی است
service_item_uuids string[] یک یا چند UUID سرویس. سرویس‌ها ذخیره می‌شوند (service_items)، اولین سرویس سرویسِ اصلی (service_item) است، و مدت/بافر روی نوبت ثبت می‌شود (service_total_minutes / service_buffer_minutes). UUID ناموجود، سرویسِ غیرbookable، سرویس بدون مدت، یا سرویسِ محیطی دیگر ⇒ 422
resource_uuid string (UUID) ⚠️ منبعی که نوبت برایش گرفته می‌شود (دستگاه، اتاق، یا خودِ پزشک). اگر داده شود doctor_uuid اختیاری است: برای منبعِ پزشک از خودش استنتاج می‌شود و برای دستگاه/اتاق از پزشک ناظرِ همان منبع؛ محل نوبت هم از شعبهٔ همان منبع می‌آید. منبع باید در همان محیط رزرو باشد و اگر سرویس انتخاب‌شده را ارائه ندهد ⇒ 422
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 نوبت ست می‌شود. پورسانت فقط وقتی واریز می‌شود که این نماینده با نماینده‌ی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت

doctor_uuid یا resource_uuid: دست‌کم یکی الزامی است؛ نبودِ هر دو ⇒ 422. مسیر قدیمیِ فقط-doctor_uuid دست‌نخورده است و سایت عمومی همان را می‌فرستد.

دستگاه پزشک نیست. وقتی resource_uuid یک دستگاه یا اتاق است، پزشک از ClinicResource.supervisor برداشته می‌شود — اپراتور کار را می‌کند و پزشک پاسخگوی بالینی است. اگر منبع ناظر نداشته باشد پیام مخصوص خودش برمی‌گردد، نه پیامِ عمومیِ «doctor_uuid یا resource_uuid لازم است» که کلاینت هر دو را فرستاده بود:

{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید","field":"resource_uuid"}]}

مسیر پنل (POST /api/v1/my/appointment) این رفتار را از قبل داشت؛ این تغییر مسیر عمومی را با آن هم‌تراز کرد.

پاسخ: علاوه بر فیلدهای قبلی، resource (uuid, name, type) و service_option (uuid, name) برمی‌گردند. نوبت‌های پیش از مدل منبع‌محور هر دو را null دارند، پس کلاینت باید با null کنار بیاید.

مدت در حالت سرویسی: مدت از ServiceBookingCalculator می‌آید — همان مؤلفه‌ای که GET /api/v1/appointment-service-slots هم با آن اسلات‌ها را می‌سازد. یعنی solo و additional سرویس‌ها لحاظ می‌شوند و نه جمعِ سادهٔ duration_minutes؛ وگرنه نوبتِ ثبت‌شده با اسلاتی که به بیمار نشان داده شده جور درنمی‌آمد. بافر جزو مدت نوبت نیست: slot_end = slot_start + service_total_minutes، و بافر جدا در service_buffer_minutes ذخیره می‌شود.

آدرس نوبت: آدرس (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_startvisit_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

{
  "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

Path Parameters

Param Type Description
uuid string (UUID) Appointment UUID

Response 200

{
  "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. null = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (default_service_category در insurance.md)
insurance_service_category_label string | null برچسب فارسی همان نوع
insurance_base_id int | null بیمهٔ پایهٔ انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد
insurance_supplementary_id int | null بیمهٔ تکمیلیِ انتخاب‌شده؛ قرارداد فعال لازم دارد و روی باقیماندهٔ بعد از بیمهٔ پایه محاسبه می‌شود (فرمول زنجیره‌ای)

نام بیمه در این پاسخ نیست؛ پنل آن را از 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

{
  "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

{
  "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. 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

{
  "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: AUTHappointments.update_status per the single-appointment access model.

Request Body

{
  "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). محاسبه زنجیره‌ای است: پایه روی کل، تکمیلی روی باقیمانده. نوبتِ بدون بیمه مثل قبل کاملاً سهم بیمار می‌ماند. مراجعهٔ بیمه‌دار همین‌جا صورتحساب نهایی و مطالبهٔ بیمه هم می‌گیرد (SessionBillingService → رویداد InvoiceFinalized؛ 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

{
  "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. The patient sees their own Timeline.

Path Parameters

Param Type Description
uuid string (UUID) Appointment UUID

Response 200

{
  "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

{
  "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 برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار > 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

{
  "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 — یافت شد

{
  "success": true,
  "data": {
    "found": true,
    "name": "علی محمدی",
    "mobile": "09123456789",
    "national_code": "0012345678"
  }
}

national_code ممکن است null باشد (بیمار قدیمی بدون کد ملی) — در این حالت فرم کد ملی را می‌گیرد.

Response 200 — یافت نشد

{ "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

{
  "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 و عدد درست در پیام:

    {
      "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

  • 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 version409 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).

{
  "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

خروجی واقعی:

{
  "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ی که در فهرست پیشنهادی نیست (مثلاً بیرون شیفت):

{
  "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 بافر مؤثر لحظهٔ ثبت

has_schedule روی فهرست پزشکان (2026-08)

GET /api/v1/my/clinic-doctors برای هر پزشک has_schedule می‌دهد: آیا در محیط جاری WeeklySchedule دارد یا نه. صفحهٔ نوبت‌ها فقط برای پزشکِ دارای برنامه تب می‌سازد — تبِ پزشکی که روز کاری ندارد جز یک تایم‌لاین همیشه‌خالی چیزی نشان نمی‌دهد.

با یک کوئری برای کل فهرست گرفته می‌شود (findByDoctors)، نه یکی به‌ازای هر پزشک.

{ "success": true, "data": { "data": [
  { "uuid": "631e81d8-…", "name": "امیر کاظمی", "has_schedule": true }
] } }

GET /api/v1/clinic/doctor-list/{clinicUuid} این فیلد را ندارد (عمومی است و نقش ادمین را سرو می‌کند)؛ کلاینت نبودِ فیلد را «نمی‌دانیم» می‌گیرد و پزشک را پنهان نمی‌کند.

فیلتر و فیلدِ منبع (2026-08)

صفحهٔ نوبت‌ها برای هر منبع تبِ مستقل دارد، پس فهرست باید بتواند «نوبت‌های همین دستگاه/اتاق» را بدهد.

پارامتر نوع توضیح
resource_uuid string اختیاری. فقط نوبت‌های همان منبع. با doctor_uuid جمع نمی‌شود — تبِ منبع جای تبِ پزشک را می‌گیرد، چون نوبتِ یک دستگاه می‌تواند از چند پزشک باشد
فیلد پاسخ نوع توضیح
resource {uuid, name}|null منبعی که نوبت رویش گرفته شده. null برای نوبت‌های پیش از مدل منبع‌محور — با leftJoin گرفته می‌شود تا آن ردیف‌ها از فهرست حذف نشوند

منبع تحت TenantFilter است: resource_uuidِ محیط دیگر هیچ ردیفی برنمی‌گرداند (۲۰۰ با فهرست خالی، نه ۴۰۳).

ثبت نوبت برای یک منبع — resource_uuid روی POST /api/v1/my/appointment (2026-08)

نوبت‌دهی منبع سرویسی است: مودالِ منبع همان فرمِ نوبت‌دهی سرویسیِ پزشک است و همین اندپوینت را صدا می‌زند، فقط با resource_uuid.

فیلد نوع توضیح
resource_uuid string منبعِ نوبت. غیرفعال یا ناموجود ⇒ 422

قواعدی که فقط وقتی این فیلد بیاید اعمال می‌شوند:

  • پزشک از ناظرِ منبع می‌آید. doctor_uuid اختیاری می‌شود؛ منبعِ بی‌ناظر ⇒ 422 (رابطهٔ پزشک↔منبع یک جا تعریف شده است و پرسیدن دوباره‌اش یعنی دو منبعِ حقیقت).
  • مدت از زنجیرهٔ حلِ همان منبع (ResourceServiceResolver) می‌آید نه از duration_minutes خامِ سرویس: همان «RF فرکشنال» روی یک دستگاه ۵۰ دقیقه است و روی دیگری ۴۰. service_durations همچنان همین نوبت را جابه‌جا می‌کند.
  • گیتِ سرویس، ResourceServiceOffering فعال است نه پرچم bookable: سرویسی که این منبع ارائه نمی‌دهد ⇒ 422 با فیلد service_item_uuids.
  • تداخل روی خودِ منبع جدا سنجیده می‌شود409. bookAtomically فقط اسلاتِ پزشک را قفل می‌کند و دو پزشک می‌توانند یک دستگاه را هم‌زمان بگیرند. اشغال از دو جا خوانده می‌شود: نوبت‌های appointments.resource_id و ردیف‌های resource_occupancy (رزرو موقت، مسدودسازیِ دستی، نوبت‌های موتور منبع‌محور). ظرفیت منبع رعایت می‌شود: اتاق دوتخته با یک نوبت پر نمی‌شود.
  • منبعِ محیط دیگر رد می‌شود422. منبع با uuid از بدنه می‌آید و TenantFilter پوششش نمی‌دهد.

محدودیت شناخته‌شده: این مسیر ردیف resource_occupancy نمی‌سازد (مثل POST /api/v1/appointment عمومی که از قبل همین‌طور بود). پس نوبتِ پنلی برای موتور منبع‌محور (appointment-availability) نامرئی است؛ در جهت عکس — پنل هر دو منبعِ اشغال را می‌خواند — مشکلی نیست.

تست: tests/Appointment/ResourceAppointmentCreateTest.php.

خروجی واقعی GET /api/v1/my/appointments?limit=1&resource_uuid=ce070910-…:

{
  "success": true,
  "data": [
    {
      "uuid": "0e5268db-f2a6-4561-86e4-5c6de0270756",
      "doctor_name": "امیر کاظمی",
      "resource": { "uuid": "ce070910-7038-4f50-9f7a-1b35ec1a67f7", "name": "اتاق ۱" },
      "slot_start": 1783859400,
      "status": "completed"
    }
  ],
  "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1, "limit": 1 }
}

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 GETs 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 — 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.

{
  "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:

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