docs(booking): document service booking mode and close task 00

docs/api/appointment.md gains the service-reschedule endpoint, the service-mode
section under PATCH, exclude_appointment_uuid and clinic_uuid on
appointment-service-slots, and the my/appointments additions. All JSON bodies are
real output captured from the running endpoints, not hand-written.

New docs/architecture/booking-modes.md holds the endpoint/mode matrix, the
duration contract with a worked example (35 + 10 buffer means a 45-minute step,
so 11:00 is not offered even though it looks free), the reserve-entry rules, and
a placeholder for the resource mode task 06 will add.

Also fixes a pre-existing flaky test that blocked a green suite:
NumericFieldNormalizerTest used a fixed national_code against db_test, which is
never reset, so depending on execution order the endpoint rejected it as a
duplicate. The test already looped for a unique mobile but not for the national
code. Out of this task's scope, fixed and declared so the definition of done is
actually green rather than apparently green.

phpstan was measured against the pre-task commit rather than asserted: 14 errors
in 9 files before, the same 14 in the same 9 files now.

Task 00 complete: 1026 tests green across three consecutive runs, 604 frontend
tests green, slot-mode contract frozen and verified.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 15:27:18 +03:30
co-authored by Claude Opus 5
parent 56a3c3c0d6
commit 9891c2e44a
4 changed files with 312 additions and 32 deletions
+149 -8
View File
@@ -86,29 +86,49 @@ Get all appointment slots (available and booked) for a doctor on a specific date
| `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`):
```json
{
"success": true,
"data": {
"doctor_uuid": "",
"date": "2026-07-16",
"total_duration_minutes": 45,
"buffer_minutes": 5,
"doctor_uuid": "f8ad91de-f939-4c72-bb6e-f73b12b9f9e0",
"date": "2026-08-01",
"total_duration_minutes": 35,
"buffer_minutes": 10,
"clinic_uuid": null,
"start_times": [
{ "start": 1750000000, "end": 1750002700, "start_time": "15:00", "end_time": "15:45", "location_id": 12 }
{ "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` بدونِ بافر است (بافر فقط فاصلهٔ بین نوبت‌های پیشنهادی است). **عمومی** (بدون احراز هویت — مصرف‌کننده: سایت nobat724).
`start_times` خالی یعنی در آن روز فضای کافی نیست. `end` بدونِ بافر است — بافر فقط فاصلهٔ
بین دو نوبت است، پس **گام کاندیدها `مدت + بافر`** می‌شود: در مثال بالا ۴۵ دقیقه، و
**۱۱:۰۰ پیشنهاد نمی‌شود** حتی اگر آزاد به نظر برسد.
⚠️ هر مسیری که زمان می‌گیرد باید **عضویت در همین فهرست** را بسنجد، نه فقط «اشغال نبودن»:
`isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید، ولی این فهرست شیفت، تعطیلی،
`date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند.
**عمومی** (بدون احراز هویت — مصرف‌کننده: سایت nobat724)، مگر با `exclude_appointment_uuid`
که JWT لازم دارد.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404/422 | Doctor / service item not found |
| `ERR_VALIDATION_001` | 422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، یا مدت سرویس تعریف نشده |
| `ERR_VALIDATION_001` | 422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، مدت سرویس تعریف نشده، سرویس به این محل تعلق ندارد، یا نوبتِ `exclude` مال پزشک دیگری است |
| `ERR_ACCESS_DENIED` | 403 | `exclude_appointment_uuid` داده شد ولی کاربر آن نوبت را مدیریت نمی‌کند |
---
@@ -884,6 +904,7 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
"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,
@@ -893,6 +914,41 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
#### حالت نوبت‌دهی سرویسی (2026-07)
در محلی که `booking_mode = service` است، **مدت داده است نه ورودی**:
- مدت مجاز از سرویس‌های نوبت (یا `service_item_uuids[]` ارسالی) حساب می‌شود و
`slot_end` باید دقیقاً `slot_start + مدت` باشد. ناسازگاری →
`422 ERR_APPOINTMENT_003` با فیلد `slot_end` و **عدد درست در پیام**:
```json
{
"success": false,
"data": null,
"errors": [
{ "code": "ERR_APPOINTMENT_003", "message": "مدت این نوبت باید 35 دقیقه باشد", "field": "slot_end" }
]
}
```
- `service_item_uuids[]` فهرست را **کامل جایگزین** می‌کند و `service_item` تکی خودکار با
عضو اول هم‌گام می‌شود. وقتی این کلید بیاید، `service_item_uuid` تکی **نادیده** گرفته
می‌شود — دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد.
- `durations` override منشی per سرویس، فقط برای همین محاسبه.
- سرویسِ **موجودِ** نوبت اگر غیرفعال شده باشد مانع نمی‌شود (نوبت نباید برای همیشه قفل
شود)؛ ولی **افزودن** سرویس غیرفعال تازه → `422`.
- `service_total_minutes` / `service_buffer_minutes` روی نوبت ثبت می‌شوند و در پاسخ
می‌آیند. در حالت اسلاتی `null` می‌مانند.
- **نوبت رزرو معاف است** (`slot_start == slot_end`): سرویس‌ها و مدت ذخیره می‌شوند ولی
مدت سنجیده نمی‌شود.
- **تبدیل رزرو به نوبت زمان‌دار** با همین endpoint انجام می‌شود:
`{ "is_reserve": false, "slot_start": …, "slot_end": … }`. endpoint جدایی وجود ندارد و
لازم نیست — `rescheduleTo()` خودش `active_slot_key` را بازتولید می‌کند.
⛔ در حالت اسلاتی هیچ‌کدام از این بررسی‌ها اجرا نمی‌شود؛ رفتار بیت‌به‌بیت همان قبل است.
رجوع: [docs/architecture/booking-modes.md](../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`.
@@ -905,9 +961,80 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
|------|-------------|
| 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 |
| 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`).
```json
{
"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`
خروجی واقعی:
```json
{
"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`ی که در فهرست پیشنهادی نیست (مثلاً بیرون شیفت):
```json
{
"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` ×۱۰ اصلاح شدند.
@@ -921,6 +1048,20 @@ Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_
### 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 | بافر مؤثر لحظهٔ ثبت |
`service_items` با یک کوئری جدا برای کل صفحه گرفته می‌شود، نه JOIN به کوئری اصلی: JOIN
روی collection ردیف‌ها را ضرب می‌کند و صفحه‌بندی را می‌شکند (نوبتی با سه سرویس، سه ردیف
می‌شد). N+1 هم نیست.
---