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
@@ -90,6 +90,46 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => {
expect(screen.getByRole('button', { name: /شارژ کیف پول/ })).toBeInTheDocument();
});
it('service mode: picks a suggested time and posts service_item_uuids', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] });
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [] });
if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [] });
// پزشک در حالت نوبت‌دهی سرویسی
if (url.startsWith('/api/v1/appointment-settings/weekly-schedule/'))
return Promise.resolve({ success: true, data: { data: { meta: { booking_mode: 'service', buffer_minutes: 5 } } } });
if (url.startsWith('/api/v1/appointment-service-slots'))
return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } });
return Promise.resolve({ success: true, data: [] });
});
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } });
// حالت سرویس: منوی زمان‌دهیِ دستی نباید باشد
await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull());
fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } });
await screen.findByRole('option', { name: 'لیزر توتال' });
fireEvent.change(screen.getByLabelText('سرویس'), { target: { value: 'it1' } });
// زمانِ خالیِ پیشنهادی ظاهر می‌شود؛ انتخاب می‌کنیم
const slotBtn = await screen.findByRole('button', { name: '15:00' });
fireEvent.click(slotBtn);
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
doctor_uuid: 'd1',
slot_start: 1754000000,
slot_end: 1754001800,
service_item_uuids: ['it1'],
is_reserve: false,
})));
});
it('reserve mode hides time fields and posts a day-level entry', async () => {
renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['r']} onClose={() => {}} isReserve />,
+8 -2
View File
@@ -88,7 +88,9 @@ Create or update the weekly schedule for a doctor (upsert).
"meta": {
"online_booking_enabled": true,
"booking_window_value": 2,
"booking_window_unit": "month"
"booking_window_unit": "month",
"booking_mode": "service",
"buffer_minutes": 5
}
}
```
@@ -98,8 +100,12 @@ Create or update the weekly schedule for a doctor (upsert).
| `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability |
| `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) |
| `booking_window_unit` | string | ❌ | `"week"` or `"month"` |
| `booking_mode` | string | ❌ | `"slot"` (پیش‌فرض) = نوبت‌دهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخاب‌شده؛ زمان‌ها با `GET /api/v1/appointment-service-slots` گرفته می‌شوند. مقدار نامعتبر نادیده گرفته می‌شود |
| `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبت‌ها (دقیقه، ≥ 0). در `slot_end` ذخیره نمی‌شود؛ فقط فاصلهٔ بین زمان‌های پیشنهادی |
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month" }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
>
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبت‌دهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد می‌شود.
**Session Config Object:**
+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).
+6 -1
View File
@@ -101,6 +101,7 @@
"insurance_covered": false,
"insurance_price_rials": null,
"duration_minutes": 50,
"bookable": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
@@ -125,7 +126,8 @@
"staff_uuid": "...",
"insurance_covered": true,
"insurance_price_rials": 200000,
"duration_minutes": 50
"duration_minutes": 50,
"bookable": true
}
```
@@ -139,6 +141,9 @@
| insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود |
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
| bookable | boolean | ❌ (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های `bookable=true` در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند |
> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته می‌شود.
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)