Confirming an appointment was supposed to create the patient's record and its session, and PatientService already knew how. Only two of the five paths that confirm an appointment ever called it, and the one that mattered most did not: a booking paid for online was confirmed inside the payment callback, which never ran the side-effects. Every Nobat724 booking therefore went unfiled — 7 confirmed appointments in dev had no session at all. The side-effects now run through AppointmentConfirmationService, which every path calls: the payment callback, both PATCH endpoints, and panel/admin bookings. Creating the record can no longer roll back a confirmation or a payment; a failure is logged and can be repaired with the new app:appointment:backfill-sessions command. Two related defects fixed along the way: - A doctor working at a clinic got two records for one appointment, one under the doctor and one under the clinic, so a single visit's revenue was counted twice. The booking context now decides, and it decides once. - That context was inferred from address_id, falling back to "the doctor's only clinic" — a guess that files an appointment under the wrong practice now that schedules are per-context. It is stored as appointments.clinic_id instead. Panel and admin bookings were left pending forever: nothing confirmed them and no payment was expected. They are created confirmed. Repeat confirmations no longer duplicate the session; an archived one still counts as filed, so archiving a mistaken visit does not resurrect it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
42 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
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) |
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 یا غایب ⇒ مدت پیشفرض سرویس |
Response 200
{
"success": true,
"data": {
"doctor_uuid": "…",
"date": "2026-07-16",
"total_duration_minutes": 45,
"buffer_minutes": 5,
"start_times": [
{ "start": 1750000000, "end": 1750002700, "start_time": "15:00", "end_time": "15:45", "location_id": 12 }
]
}
}
start_times خالی یعنی در آن روز فضای کافی نیست. end بدونِ بافر است (بافر فقط فاصلهٔ بین نوبتهای پیشنهادی است). عمومی (بدون احراز هویت — مصرفکننده: سایت nobat724).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
404/422 | Doctor / service item not found |
ERR_VALIDATION_001 |
422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، یا مدت سرویس تعریف نشده |
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 |
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": 1, "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 week or 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). فقط وقتی duration_from_services=true باشد سرور آن را از slot_start + Σ duration_minutes بازمحاسبه میکند؛ در غیر این صورت مقدار کلاینت حفظ میشود |
service_item_uuids |
string[] | ❌ | یک یا چند UUID سرویس که به نوبت پیوست میشوند (چند سرویس). اولین سرویس بهعنوان سرویسِ اصلی (service_item) ثبت و همه در service_items برمیگردند. UUID ناموجود ⇒ 422 |
duration_from_services |
boolean | ❌ | true = حالت نوبتدهی سرویسی: مدت نوبت از مجموع duration_minutes سرویسها محاسبه و slot_end بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ 422. پیشفرض false (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ میشود) |
service_durations |
object | ❌ | override مدت هر سرویس بهصورت { "<service_uuid>": <minutes> } — فقط وقتی duration_from_services=true. برای نوبتدهیِ منشی که مدت را برای همان نوبت تغییر میدهد؛ در محاسبهٔ slot_end لحاظ میشود و مقدار پیشفرضِ سرویس تغییر نمیکند. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض |
for_self |
boolean | ❌ | true (default) = patient is the logged-in payer; false = booking for someone else |
patient_name |
string | ⚠️ | Required when for_self=false; otherwise filled from the payer's profile |
patient_mobile |
string | ⚠️ | Required when for_self=false; otherwise the payer's mobile |
patient_national_code |
string | ✅ | کد ملی بیمار — همیشه الزامی (هر دو حالت for_self). باید ۱۰ رقم معتبر باشد (isValidIranNationalCode)؛ ارقام فارسی به انگلیسی تبدیل میشوند |
patient_gender |
string | ✅ | جنسیت بیمار — همیشه الزامی. ورودی man/male یا woman/female پذیرفته میشود و به فرمِ متعارف man/woman ذخیره میگردد |
patient_reason |
string | ❌ | Reason for visit |
note |
string | ❌ | Patient note |
city_id |
integer | ❌ | شناسهی شهرِ دامنهی جاری (از city.json سایت). برای گاردِ پورسانت نماینده: اگر شهر نمایندهی فعال داشته باشد، booking_representation_id نوبت ست میشود. پورسانت فقط وقتی واریز میشود که این نماینده با نمایندهی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت |
آدرس نوبت: آدرس (
address_id) ارسالی نیست؛ سرور آن را از رویlocation_idهمان session در برنامهی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره میکند. در پاسخ بهصورتaddress_idبرمیگردد. همهی مسیرهای رزرو (آنلاینPOST /api/v1/appointment، منشیPOST /api/v1/my/appointment، ادمین) آدرس را به همین شکل ست میکنند.
تضمین عدم رزرو دوگانه: هر سه مسیر رزرو از
AppointmentRepository::bookAtomically()عبور میکنند و یک قید یکتای دیتابیسی (active_slot_key) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر(doctor, slot_start)ممکن است و درخواست بازنده409 SLOT_TAKENمیگیرد. نوبتهای لغو/منقضی اسلات را آزاد میکنند (کلیدNULL). علاوه بر این،bookAtomicallyداخل تراکنش یک قفلِ per-doctor (PESSIMISTIC_WRITEروی ردیف پزشک) میگیرد؛ چون در حالت سرویسی نوبتها طول متغیر و شروعِ متفاوت دارند و قید یکتای(doctor, slot_start)تداخلِ بازهایِ دو رزروِ همزمان با شروعِ متفاوت را نمیگیرد. این قفل بررسیِ overlap و insert را نسبت به سایر رزروهای همان پزشک اتمیک میکند.
Auto-add to clinic: هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (
DoctorAddress.clinic_id)، بیمار علاوه بر پروندهی پزشک، به پروندههای آن کلینیک هم اضافه میشود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه میشود. هر شاخه مشروط به فعالبودنpatient_records. جزئیات درdocs/api/patient.md.Auto-fill session on confirm: پروندهای که هنگام تأیید نوبت خودکار ساخته میشود، اکنون از خود نوبت پر میشود:
session_at= زمان واقعی نوبت (slot_start)،visit_price_rials= هزینه ویزیت نوبت (در نبود آن، «قیمت ویزیت آزاد» تنظیمات)، برای هر سرویسِ نوبت یک ردیفSessionServiceبا قیمت snapshot از خود سرویس، وservices_total_rials/final_price_rialsمحاسبهشده. قبلاً همهی این مقادیر صفر/خالی ثبت میشدند (باگ).
Payer vs patient: the authenticated user (
user) is always the payer; 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 |
GET /api/v1/appointment/{uuid}
Get appointment detail.
Permission: AUTH — must be the patient, the doctor, or ROLE_ADMIN
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": "...",
"created_at": 1717000000
}
}
doctor.specialtiesآرایه (ممکن است خالی)؛addressاولین آدرس پزشک است (ممکن استnullاگر پزشک آدرسی ندارد).address.map.latitude/longitudeرشته یاnull. تاریخها Unix.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not the patient/doctor/admin |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
GET /api/v1/appointments/doctor/{doctorUuid}
Get all appointments for a specific doctor.
Permission: AUTH — must be the doctor, their secretary, or ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
doctorUuid |
string (UUID) | Doctor UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
status |
string | ❌ | Filter: pending, confirmed, cancelled, completed, no_show |
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_FORBIDDEN_001 |
403 | Not authorized to view this doctor's appointments |
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 — patient can cancel; doctor/secretary can confirm/complete/no_show; admin can do all
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 Role:
| Actor | Allowed transitions |
|---|---|
| Patient | pending → cancelled |
| Doctor / Secretary | pending → confirmed, confirmed → completed, confirmed → no_show |
| Admin | Any transition |
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_FORBIDDEN_001 |
403 | Not authorized for this transition |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
ERR_CONFLICT_001 |
409 | Version mismatch (optimistic lock) |
ERR_VALIDATION_001 |
422 | Invalid status value |
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 — caller must be able to manage the appointment (canManage).
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_AUTH_006 |
403 | Not allowed to manage 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.
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جدا میشود).
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
| Param | Type | Required | Description |
|---|---|---|---|
mobile |
string | ✅ | شماره موبایل ایران (^09\d{9}$)؛ ارقام فارسی به انگلیسی تبدیل میشوند |
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 mobile (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 for the linked doctor (empty if appointments.view permission is false) |
(plain patient ROLE_USER) |
The patient's own appointments (a.user = current user) |
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}`), `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`
### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** appointment's patient, owning doctor, or admin.
```json
{
"slot_start": 1731000000, "slot_end": 1731001800,
"is_reserve": false,
"service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…",
"deposit_required": true, "deposit_amount_rials": 5000000,
"note": "…", "patient_name": "…", "patient_mobile": "…",
"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. 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.- Optimistic lock via
version→409on concurrent edit.
Response 200: { success, data: { data: <appointment.toArray()> } }
| HTTP | Description |
|---|---|
| 404 | نوبت یافت نشد |
| 403 | not patient/doctor/admin |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition |
| 409 | slot taken or version conflict |
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).
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 — whitelisted in config/packages/security.yaml (both the
public_endpoints firewall pattern and an access_control entry).
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.