feat(appointment): add service mode functionality and update API documentation for service-based booking

This commit is contained in:
hamed
2026-07-15 23:22:57 +03:30
parent 5937f7e176
commit ad94c165e8
4 changed files with 95 additions and 6 deletions
+41 -3
View File
@@ -68,6 +68,42 @@ Get all appointment slots (available and booked) for a doctor on a specific date
---
## 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 |
### Response `200`
```json
{
"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` بدونِ بافر است (بافر فقط فاصلهٔ بین نوبت‌های پیشنهادی است).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404/422 | Doctor / service item not found |
| `ERR_VALIDATION_001` | 422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، یا مدت سرویس تعریف نشده |
---
## 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.
@@ -143,7 +179,8 @@ Book an appointment slot.
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
| `slot_end` | integer | | Slot end (Unix timestamp) |
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). در حالت سرویسی که `service_item_uuids` ارسال شود، سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه می‌کند و مقدار کلاینت نادیده گرفته می‌شود |
| `service_item_uuids` | string[] | ❌ | حالت نوبت‌دهی سرویسی: یک/چند UUID سرویسِ `bookable`. مدت نوبت = مجموع `duration_minutes` آن‌ها؛ اولین سرویس روی نوبت ثبت می‌شود. سرویسِ غیرbookable یا بدون مدت ⇒ `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 |
@@ -155,7 +192,7 @@ Book an appointment slot.
> **آدرس نوبت:** آدرس (`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`).
> **تضمین عدم رزرو دوگانه:** هر سه مسیر رزرو از `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`.
@@ -564,8 +601,9 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
| 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`, `is_reserve`.
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`.
`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[]` (حالت نوبت‌دهی سرویسی، غیرِ رزرو): یک/چند سرویسِ `bookable`؛ `slot_end` سمت سرور از `slot_start + Σ duration_minutes` محاسبه می‌شود و اولین سرویس روی نوبت ثبت می‌گردد. سرویسِ غیرbookable یا بدون مدت ⇒ `422`.
### 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).