- Implemented PublicResourceBookingController to handle public resource booking requests. - Added methods for retrieving bookable resources, available slots, and month availability. - Created PublicResourceBookingService to manage public resource offerings and service visibility. - Developed tests for public resource booking to ensure correct functionality and error handling.
86 KiB
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 theappointmentspermission), passingmanagement=1bypasses 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,managementis 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: falsefirewall onto the JWT firewall so a bearer token can be authenticated on them; anonymous callers still reach them via thePUBLIC_ACCESSaccess-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: falsemeans 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-livepending(not yet expired). Onlycancelled_by_user/cancelled_by_doctor/expiredrelease the slot. Session boundaries match the doctor'sWeeklyScheduleor date override config.Returns an empty
sessionsarray 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 (seemetainappointment-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 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
{
"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داده شود:
- قفل و بررسی تداخلِ per-doctor اعمال نمیشود و
active_slot_keyنال میماند. پیش از این کلینیکی که چند دستگاه زیر نظر یک پزشک داشت نمیتوانست دوتایشان را همساعت رزرو کند.- تضمینِ یکتایی از
resource_occupancyمیآید که ظرفیت (capacity) را میفهمد؛ اتاق سهنفره سه رزرو همزمان میپذیرد و چهارمی409میگیرد.- شعبهٔ نوبت از خودِ منبع برداشته میشود، نه از برنامهٔ هفتگی پزشک.
- ساعت نوبت به مرز پنجدقیقهای گرد میشود (هر دو سر رو به پایین). اسلاتهای برنامهٔ هفتگی از قبل ترازند و تغییری نمیکنند؛ فقط «ثبت خارج از برنامه (ورود دستی ساعت)» جابهجا میشود. بدون این، دو نوبتِ پشتسرهم سطلِ اشغالِ مشترک پیدا میکردند و دومی
409میگرفت با اینکه یک ثانیه هم روی هم نبودند.- لغو نوبت، اشغال منبع را آزاد میکند.
نوبت بدون منبع دقیقاً مثل قبل با کلید و قفل پزشک محافظت میشود.
مدت نوبت روی منبع (2026-08). وقتی
resource_uuidداده شده و منبع همهٔ سرویسهایservice_item_uuidsرا با ردیفِ ارائهٔ فعال و توگلِ «نمایش در نوبتدهی آنلاین» روشن ارائه میدهد،slot_endاز زنجیرهٔ حلِ خودِ منبع محاسبه میشود (ResourceServiceResolver) — همان عددی کهappointment-resource-slotsبا آن زمانها را ساخته. پیش از این مدت همیشه ازServiceBookingCalculatorپزشکمحور میآمد، پس نوبتِ ثبتشده با اسلاتی که به بیمار نشان داده شده بود یکی نمیشد.شرطی است و نه همیشگی — سه رفتار که با هم فرق دارند:
- ردیف ارائه هست و فعال + سرویس روشن ⇒ مدت از منبع. مثلاً سرویسِ ۳۰ دقیقهایِ پیشفرض با
duration_minutes = 45روی این دستگاه، نوبتِ ۴۵ دقیقهای میسازد.- هیچ ردیفی نیست ⇒ مثل قبل، مدت از پیشفرضِ خودِ سرویس. محیطی که هنوز رابطههای منبع↔سرویس را پر نکرده نباید یکشبه نوبتدهیاش قطع شود.
- ردیف هست ولی غیرفعال ⇒
422با «این منبع این سرویس را ارائه نمیدهد». غیرفعال حرفِ صریحِ مالک است، نه سکوت.گیتِ
bookableهم دور زده نمیشود: سرویسی که توگلش خاموش است حتی با ردیفِ ارائهٔ فعال422میگیرد («این سرویس برای نوبتدهی فعال نیست») — همان رفتار مسیر پزشکمحور. مسیر پنل (POST /api/v1/my/appointment) عمداً سختگیریِbookableرا ندارد.پاسخ: علاوه بر فیلدهای قبلی،
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_start)،visit_price_rials= هزینه ویزیت نوبت (در نبود آن، «قیمت ویزیت آزاد» تنظیمات)، برای هر سرویسِ نوبت یک ردیفSessionServiceبا قیمت snapshot از خود سرویس، وservices_total_rials/final_price_rialsمحاسبهشده. قبلاً همهی این مقادیر صفر/خالی ثبت میشدند (باگ).
Payer vs patient: the authenticated user (
user) is always the payer; thepatient_*fields describe who the visit is for and are stored separately. Temporary lock: the slot is held by the newpendingbooking for 15 minutes (expires_at = created_at + 900). If payment is not completed in time, the booking is moved toexpiredand the slot is freed (seeapp: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
service_sectionاینجا استنتاجی است. نوبتِ سرویسیِ کلینیک بخش را روی خودِ نوبت ذخیره نمیکند و فقط سرویس را نگه میدارد. این اندپوینت — و فقط این اندپوینت — وقتیservice_sectionخالی باشد آن را ازservice_itemدرمیآورد. حدس نیست:service_items.section_idستونیNOT NULLاست، پس هر سرویسی دقیقاً یک بخش دارد. نوبتِ بدون هیچ سرویسی همچنانnullمیگیرد. فهرستها (GET /api/v1/appointments…) عمداً این استنتاج را ندارند و رفتارشان تغییر نکرده.
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 — عمداً بدون مجوزِ رجیستری.
این اندپوینت
a.user = خودِ کاربررا میدهد، یعنی نوبتهای خودِ فرد بهعنوان بیمار، نه دادهٔ محیط. مصرفکنندهاش داشبورد بیمار درnobat724_frontاست. آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص داده شد: گِیتِappointments.viewیعنی منشیای که جایی بیمار است نوبتهای شخصیاش را نبیند. درApiLeastPrivilegeTest::ALLOWED_200با همین دلیل ثبت است.
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_doctororcancelled_by_user, anAppointmentEvent(typecancelled, title «نوبت لغو شد») is recorded with the actor (user id + name), the optionalcancel_reason, and the cancel time — surfaced viaGET /api/v1/appointment/{uuid}/events. Awarning-level entry is also written toapp_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.
پروندهٔ درمان هم اینجا باز میشود. اگر سرویسِ نوبت «طول درمان» فعال داشته باشد، همین اندپوینت پروندهٔ چندجلسهای را میسازد و نوبت را به اولین جلسهٔ بدوننوبت وصل میکند — دقیقاً مثل
PATCH /api/v1/appointment/{uuid}/status. شکستِ این مرحله نوبت و پرداخت را برنمیگرداند؛ فقط لاگ میشود. پروندههای جامانده راphp bin/console app:treatment:backfill-cases --fixمیسازد.
Permission: AUTH — appointments.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
- انتخاب بیمه (اگر در بدنه آمده باشد) روی نوبت مینشیند و اعتبارسنجی میشود.
pending → confirmed(state machine still applies).AppointmentConfirmationServicefiles 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. APatientSessionis opened with the visit price and one line per attached service.- تفکیک بیمه روی همان مراجعه محاسبه میشود (
BillingCalculator): درصد پوشش از نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve (insurance.md). محاسبه زنجیرهای است: پایه روی کل، تکمیلی روی باقیمانده. نوبتِ بدون بیمه مثل قبل کاملاً سهم بیمار میماند. مراجعهٔ بیمهدار همینجا صورتحساب نهایی و مطالبهٔ بیمه هم میگیرد (SessionBillingService→ رویدادInvoiceFinalized؛ billing.md). - Each payment row is registered on that visit (
walletalso 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
confirmedinAppointmentStatusDropdownopens the «قطعی کردن نوبت» modal rather than issuing a rawPATCH .../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
Permission: appointments.create — علاوه بر بررسی نقش (ROLE_DOCTOR/ROLE_CLINIC/ROLE_SECRETARY/ROLE_ADMIN).
تا پیش از ۲۰۲۶-۰۸-۰۸ فقط نقش بررسی میشد، پس منشیِ appointments.create:false هم نوبت ثبت میکرد.
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(«ثبت شده»), notconfirmed. 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
pendingappointment still occupies its slot (so the time stays reserved) and carries noexpires_at, so it is never auto-expired: only online gateway holds (created with a 15-minute TTL byPOST /api/v1/appointment) are swept byAppointmentExpiryService. 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 theappointments.createpermission; admin onto any. Otherwise403 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 — Permission: appointments.create
برخلاف
GET /api/v1/patient/search-user، این endpoint به فیچرpatient_recordsاشتراک وابسته نیست وROLE_ADMINرا هم میپذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.مجوزش عمداً
appointments.createاست نهpatients.view: بخشی از فرمِ ثبت نوبت است، و با گیتِ پروندهٔ بیمار، منشیای که فقط اجازهٔ نوبتدهی دارد فرمش را از دست میداد.
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 — Permission: appointments.view
از ۲۰۲۶-۰۸-۰۸ گِیت دارد. پیش از آن منشیِ
appointments:falseبا درخواست مستقیم به API همان فهرستی را میگرفت که توگل، دکمهاش را در پنل پنهان کرده بود.
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 — Permission: appointments.view. A caller with no
resolvable scope (clinic/doctor row missing, or no assigned doctors) gets all-zero counts
rather than an unscoped, system-wide count. A plain patient gets counts over their own
appointments only.
پیش از ۲۰۲۶-۰۸-۰۸ منشیِ بدون
appointments.viewبهجای ۴۰۳ صفر میگرفت. صفرِ خاموش با «اجازه نداری» یکی نیست؛ حالا ۴۰۳ میگیرد.
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_endmust 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تکی نادیده گرفته میشود — دو منبع برای یک چیز به نوبتِ ناسازگار میرسد. -
durationsoverride منشی 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.statusfollows the same transition rules asPATCH /appointment/{uuid}/status. A transition tocancelled_by_doctor/cancelled_by_userrecords a cancellation event (Timeline) +app_logwarning; an optionalcancel_reasonbody field is stored on the event. An inline cancellation is gated onappointments.cancelexactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.- Optimistic lock via
version→409on 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_uuidwill 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:
- the context has at least one registered address, and
- 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 + appointments.view
پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تبها/تایملاینِ صفحهٔ نوبتها. برخلاف
GET /api/v1/clinic/doctor-list/{clinicUuid} که روی firewallِ عمومی است و همهٔ پزشکانِ
کلینیک را برمیگرداند، این اندپوینت احرازشده است و نتیجه را بر اساس نقش محدود میکند:
- منشیِ محیطِ کلینیک → فقط پزشکانِ تخصیصیافته به همان منشی (
DoctorSecretaryفعال). - منشیِ محیطِ مطب → همان یک پزشک.
- کلینیک → همهٔ پزشکانِ کلینیک · پزشک → خودش.
بدونِ این، منشیِ کلینیک پزشکی را در تب میدید که برایش مجوزِ نوبت نداشت و روی
slot/booking، 403 میگرفت.
Response 200:
{ "success": true, "data": { "data": [ { "uuid": "…", "name": "دکتر …" } ] } }