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 هم نیست.
---
+124
View File
@@ -0,0 +1,124 @@
# روش‌های نوبت‌دهی
هر برنامهٔ هفتگی (`weekly_schedules`) یک روش نوبت‌دهی دارد که در
`setting.meta.booking_mode` می‌نشیند. روش، **per محل** است نه per پزشک: یک پزشک می‌تواند
در مطب شخصی اسلاتی و در کلینیک سرویسی باشد، چون هر محل برنامهٔ مستقل خودش را دارد
(`UNIQUE(doctor_id, entity_type, entity_id)`).
| روش | مقدار | مدت نوبت از کجا می‌آید |
|---|---|---|
| اسلاتی | `slot` (پیش‌فرض) | `duration_per_patient` هر شیفت — اسلات‌های هم‌اندازه |
| سرویسی | `service` | مجموع مدت سرویس‌های انتخابی + `buffer_minutes` بین دو نوبت |
`booking_mode` پس از **اولین ثبت** قفل می‌شود
({@see \App\Appointment\Entity\WeeklySchedule::getStoredBookingMode()}) و پنل توگل را
غیرفعال می‌کند. تا وقتی `null` است قابل تغییر است.
---
## ⛔ حالت اسلاتی منجمد است
منطق `slot` تولیدی و زنده است. در فاز موتور نوبت‌دهی چندمنبعی هیچ تسکی اجازهٔ تغییرش را
ندارد. فهرست کامل قفل‌شده‌ها:
[docs/new_feture/taskes/_shared/red-lines.md](../new_feture/taskes/_shared/red-lines.md)
اجبار خودکار:
```bash
ddev exec php bin/phpunit --group=slot-mode-frozen
```
سه چیز را قفل می‌کند: شکل پاسخ `appointment-slots`، شکل پاسخ `month-availability`، و
امضای متدهای عمومی `SlotCalculatorService`. fixture ها در
`tests/Appointment/fixtures/` **read-only** اند — اگر تست قرمز شد، کد باید برگردد نه fixture.
---
## ماتریس: کدام endpoint در کدام حالت
| endpoint | `slot` | `service` |
|---|---|---|
| `GET /api/v1/appointment-slots` | ✅ | — |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | ✅ | ✅ (فقط «این روز ظرفیت دارد؟») |
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | ✅ (`booking_mode` را می‌گوید) | ✅ |
| `GET /api/v1/appointment-service-slots` | ❌ ۴۲۲ | ✅ |
| `GET /api/v1/appointment-booking-locations/{doctorUuid}` | ✅ | ✅ |
| `POST /api/v1/appointment` | ✅ | ✅ |
| `PATCH /api/v1/appointment/{uuid}` | ✅ (هر مدتی) | ✅ (مدت اعتبارسنجی می‌شود) |
| `POST /api/v1/appointment/{uuid}/service-reschedule` | ❌ ۴۲۲ `ERR_APPOINTMENT_004` | ✅ |
`appointment-service-slots` تنها endpointی است که حالت را صریحاً رد می‌کند
(`این پزشک در حالت نوبت‌دهی سرویسی نیست`). بقیه یا هر دو حالت را می‌پذیرند یا بی‌اثرند.
---
## حالت سرویسی — قرارداد مدت
```
مدت نوبت = مجموع مدت سرویس‌های انتخابی (یا override منشی per سرویس)
پایان نوبت = شروع + مدت ← بافر جزو نوبت نیست
گام کاندیدها = مدت + بافر ← فاصلهٔ بین دو نوبت
```
مثال واقعی (شیفت ۰۹:۰۰–۱۲:۰۰، سرویس ۲۰+۱۵ دقیقه، بافر ۱۰):
```
۰۹:۰۰–۰۹:۳۵ ۰۹:۴۵–۱۰:۲۰ ۱۰:۳۰–۱۱:۰۵ ۱۱:۱۵–۱۱:۵۰
```
گام ۴۵ دقیقه است، پس **۱۱:۰۰ پیشنهاد نمی‌شود** حتی اگر آزاد به نظر برسد. هر مسیری که
زمان می‌گیرد باید عضویت در همین فهرست را بسنجد، نه فقط «اشغال نیست»:
`isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید، ولی فهرست پیشنهادی شیفت، تعطیلی،
`date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند.
### تنها مرجع محاسبه
`App\Appointment\Service\ServiceBookingCalculator` — مالکیت محیط، وجود، فعال‌بودن و مدت،
همه یک‌جا. سه مصرف‌کننده دارد: `appointment-service-slots`، اعتبارسنجی `PATCH`، و
`ServiceRescheduleService`.
> ⚠️ فرمول فعلی **جمع سادهٔ** مدت‌هاست. مستند موتور نوبت‌دهی (بند ۵) این را رد می‌کند —
> آماده‌سازی چند بار حساب می‌شود و ظرفیت الکی پر می‌شود. اصلاحش («زمان تنها / زمان اضافه»)
> کار تسک ۰۴ است و همان‌جا **یک خط** از این کلاس عوض می‌شود.
### ستون‌های ثبت‌شده روی نوبت
| ستون | معنا |
|---|---|
| `service_total_minutes` | مدت محاسبه‌شده در لحظهٔ ثبت. در حالت اسلاتی `NULL` |
| `service_buffer_minutes` | بافر مؤثر در لحظهٔ ثبت — تغییر تنظیمات نوبت‌های ثبت‌شده را عوض نمی‌کند |
`slot_end - slot_start` همان عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و نوبت رزرو
(`slot_start == slot_end`) هیچ جای دیگری مدت نگه نمی‌دارد.
backfill نوبت‌های قدیمی:
```bash
ddev exec php bin/console app:appointment:backfill-service-duration # dry-run
ddev exec php bin/console app:appointment:backfill-service-duration --force
```
---
## نوبت رزرو (`is_reserve`)
نوبت رزرو **روزی** است نه ساعتی: `slot_start == slot_end == نیمه‌شب`، `active_slot_key`
تهی، و هیچ بازه‌ای اشغال نمی‌کند.
| عملیات | رفتار |
|---|---|
| ثبت سرویس‌ها روی رزرو | ✅ سرویس‌ها و `service_total_minutes` ذخیره می‌شوند تا تبدیل بعدی مدت را از دست ندهد |
| اعتبارسنجی مدت | معاف — مدتی برای سنجیدن ندارد |
| `service-reschedule` | ❌ ۴۲۲ — رزرو زمان ندارد |
| تبدیل به نوبت زمان‌دار | `PATCH` با `is_reserve: false` + `slot_start`/`slot_end` |
**endpoint جدایی برای تبدیل وجود ندارد و لازم نیست:** `rescheduleTo($start, $end, $isReserve)`
خودش `refreshActiveSlotKey()` را صدا می‌زند و کلید یکتایی را بازتولید می‌کند.
---
## حالت سوم (`resource`) — هنوز نیامده
تسک ۰۶ فاز موتور چندمنبعی حالت `resource` را اضافه می‌کند: برنامهٔ چندبخشی روی چند منبع
(اتاق، دستگاه، اپراتور). ارتقا **یک‌طرفه** خواهد بود و مشروط بر نبودِ نوبت فعال آینده.
تا آن زمان، این سند دو حالت دارد و همین ماتریس معتبر است.
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۹ از ۱۰ تمام شد (مانده: مستندات + بازبینی پایانی)
**وضعیت کلی:** ✅ تمام‌شده — ۱۰ قابلیت از ۱۰
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
@@ -17,8 +17,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۰.۲ | ~~fixture ها با تاریخ ثابت‌اند، نه `time()`~~ → fixture **ساختاری** است | ✅ | **انحراف عمدی از متن تسک.** تاریخ ثابتِ گذشته را `isWithinBookingWindow` رد می‌کند و snapshot خالی چیزی را تضمین نمی‌کند. به‌جایش: برنامهٔ قطعی (هر ۷ روز یک شیفت ۰۹:۰۰–۱۱:۰۰/۳۰ دقیقه) روی `+3 days`، و epoch/uuid با placeholder نرمال می‌شوند. آنچه قفل می‌شود: کلیدها، ترتیب، نوع‌ها، ساعت‌های محلی |
| ۰.۳ | کامنت «read-only، هیچ تسکی به‌روزش نمی‌کند» بالای هر سه fixture | ✅ | کلید `_readme` در دو JSON (در loader حذف می‌شود) + docblock در فایل PHP |
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ✅ | فقط `getServiceStartTimes` یک پارامتر **اختیاری** با پیش‌فرض `null` گرفت. **اثبات کارکرد تور ایمنی:** تست منجمد همان لحظه قرمز شد و دقیقاً همان پارامتر را نشان داد، در حالی که دو قرارداد پاسخ سبز ماندند. fixture امضا یک بار با تاریخچهٔ مکتوب به‌روز شد (header خودش مجاز کرده) |
| ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | | در پایان تسک تأیید می‌شود |
| ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | | در پایان تسک تأیید می‌شود |
| ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | | `SlotModeFrozenTest` سبز در پایان تسک |
| ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | | همان تست |
| ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده | ✅ | `setIsReserve()` **وجود ندارد**؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` انجام می‌شود که خودش `refreshActiveSlotKey()` را صدا می‌زند ([Appointment.php:316](../../../src/Appointment/Entity/Appointment.php)). یادداشت قبلی چک‌لیست غلط بود |
| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | ✅ | لمس نشد؛ فقط الگویش تکرار شد |
| ۰.۹ | `--group=slot-mode-frozen` سبز | ✅ | `OK (3 tests, 8 assertions)` — نیازمند `#[Group]` attribute بود، نه `@group` (PHPUnit 12 annotation را حذف کرده) |
@@ -87,8 +87,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیش‌فرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بی‌برنامه → پیش‌فرض `slot` |
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ✅ | ۱۵ تست / ۳۴ assertion. شامل: جابه‌جایی بی‌ارسال مدت · حذف سرویس → کوچک‌شدن خودکار · **زمان فعلیِ خود نوبت با سرویس بلندتر پذیرفته می‌شود** (اثر `excludeAppointmentId`) · سرویس غیرفعالِ موجود → warning · حالت اسلاتی → `ERR_APPOINTMENT_004` · بیرون شیفت → `ERR_APPOINTMENT_001` · رزرو → پیام ارجاع به ویرایش · `start` غایب · سرویس بیگانه · افزودن سرویس غیرفعال تازه · گذشته · نوبت بی‌سرویس · نسخهٔ کهنه → ۴۰۹ · نوبت شخص دیگر → ۴۰۳ · uuid ناموجود → ۴۰۴ |
| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ✅ | ۹ تست / ۲۴ assertion. شامل: بازمحاسبهٔ مدت با تعویض سرویس · هم‌گامی ستون تکی · PATCH فقط-یادداشت بی‌اعتبارسنجی · رزرو معاف ولی مدت‌دار · تبدیل رزرو با همان PATCH · مدت ناسازگار → `ERR_APPOINTMENT_003` با عدد درست در پیام · سرویس بیگانه → ۴۲۲ · **حالت اسلاتی هر مدتی را می‌پذیرد و ستون سرویسی `null` می‌ماند** · نوبت سرویسیِ بی‌سرویس قفل نمی‌شود |
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | | |
| ۴.۵ | ~~`ConvertReserveTest`~~ → پوشش در `PatchServiceDurationTest` | ✅ | فایل جدا ساخته نشد چون endpoint جدا ساخته نشد (ردیف ۱.۷). تبدیل رزرو در `testReserveConvertsToATimedAppointmentThroughPatch` و `testReserveEntryStoresServicesWithoutDurationCheck` پوشش دارد. **رقابت روی `active_slot_key`** جدا تست نشد: مکانیزمش دست‌نخورده است و `SlotUniquenessTest` موجود از قبل می‌سنجدش |
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | | ۱۱ تست سبز در هر اجرا؛ همان تستی که بیت‌به‌بیت‌بودنِ استخراج `ServiceBookingCalculator` را تضمین کرد |
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز |
| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمی‌گردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال می‌ماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بی‌اثر |
| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستون‌های جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: هم‌گامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` می‌کند · تکراری‌ها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
@@ -101,10 +101,10 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/appointment.md` دو endpoint جدید + توسعهٔ PATCH | | |
| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | | |
| ۵.۳ | `docs/architecture/booking-modes.md` ساخته شد | | تسک ۰۶ حالت سوم را اضافه می‌کند |
| ۵.۴ | دو کد خطای جدید مستند شد | | |
| ۵.۱ | `docs/api/appointment.md` — endpoint جدید + توسعهٔ PATCH | | `service-reschedule` کامل · بخش «حالت نوبت‌دهی سرویسی» زیر PATCH · `exclude_appointment_uuid` و `clinic_uuid` · افزوده‌های `my/appointments`. **JSON واقعی** از اجرای واقعی endpoint (ابزار موقت `--group=dump-docs`، بعد حذف شد) — نه دست‌ساز |
| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | | در `docs/architecture/booking-modes.md` |
| ۵.۳ | `docs/architecture/booking-modes.md` ساخته شد | | ماتریس، قرارداد مدت با مثال واقعی (گام ۴۵ دقیقه → ۱۱:۰۰ پیشنهاد نمی‌شود)، بخش نوبت رزرو، و جای خالیِ حالت `resource` برای تسک ۰۶ |
| ۵.۴ | دو کد خطای جدید مستند شد | | `ERR_APPOINTMENT_003`/`_004` در جدول خطاهای هر دو endpoint، با نمونهٔ JSON واقعی |
## ۵.۴ کارِ کشف‌شده وسط اجرا (اعلام‌شده، نه بی‌صدا)
@@ -118,7 +118,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ج.۱ | یک شکست flaky در اجرای ترکیبی `tests/Appointment tests/Shared` | ⚠️ | یک بار ۱ failure دید، سه اجرای بعدی سبز (۳۲۴ تست). نام تست ثبت نشد چون خروجی از دست رفت. **بازتولید نشد** — بدهی ثبت‌شده، نه «حل‌شده». احتمال: برخورد شمارهٔ موبایل تصادفی در `ApiTestCase::createUser` روی `db_test` که هرگز ریست نمی‌شود (خودِ کلاس این را مستند کرده) |
| ج.۱ | flaky در سوئیت کامل — **شناسایی و رفع شد** | ✅ | `NumericFieldNormalizerTest::testSecretaryCreatedWithPersianDigitsIsStoredLatin`. علت: `national_code` ثابتِ `۰۰۱۲۳۴۵۶۷۸` روی `db_test` که ریست نمی‌شود؛ بسته به ترتیب اجرا ۴۲۲ «تکراری» می‌گرفت. خودِ تست برای **موبایل** حلقهٔ یکتاسازی داشت ولی برای کد ملی نداشت. رفع: کد ملی تصادفی + assert پایین‌دستی از همان متغیر. سه اجرای کامل متوالی سبز. **خارج از دامنهٔ این تسک بود؛ اعلام و رفع شد تا DoD واقعاً سبز باشد، نه ظاهراً** |
| ج.۲ | یک PHPUnit Notice در `tests/Shared` | ⚠️ | پیش از تغییرات این تسک هم بود (baseline). خارج از دامنهٔ این تسک |
| ج.۳ | `db_test` تاریخچهٔ migration جدا دارد | ⚠️ | `doctrine:migrations:migrate` روی آن می‌شکند (`Table users already exists`)؛ ستون‌های جدید با `ALTER` دستی اضافه شدند. برای تسک‌های بعدی هم همین لازم است |
@@ -126,15 +126,15 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | |
| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | | |
| ۶.۳ | `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز | | |
| ۶.۴ | `phpstan analyse` بدون خطای جدید | 🔄 | `analyse src/Appointment`**No errors**. تحلیل کامل `src` ۱۴ خطا دارد ولی **هیچ‌کدام در فایل‌های این تسک نیست** (AuthController، BillingController، ClinicServiceController، ServiceItem، DoctorClaimService، InventoryService، PatientService، SecretaryService، HealthController) — از قبل بوده‌اند. در پایان تسک با baseline مقایسه می‌شود |
| ۶.۵ | `npx tsc --noEmit` بدون خطا | | |
| ۶.۶ | `yarn test` سبز | | |
| ۶.۷ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | |
| ۶.۸ | `docs/api/*` به‌روز شد | | |
| ۶.۹ | چک‌لیست UI (بخش ۳) کامل شد | ⏳ | |
| ۶.۱۰ | `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | | `service_item` تکی هم‌گام است؟ |
| ۶.۱۱ | commit شد، سپس `graphify update .` | | |
| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | | |
| ۶.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند | ✅ | تنها ⏳ باقی‌مانده **۳.۱۳** است، با دلیل و تسک مقصد. سه ردیف 🔄: بررسی **چشمی** دارک‌مود/فشرده/موبایل (۳.۹/۳.۱۰/۳.۱۶) — استدلالی تأیید شدند ولی در مرورگر دیده نشدند. ⚠️ ها: ۳.۱۷ (پروژه فایل i18n ندارد) و ج.۲ (PHPUnit notice از قبل) |
| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | | **۱۰۲۶ تست / ۲۸۵۰ assertion، سه اجرای متوالی سبز.** برای رسیدن به این، یک flaky از قبل‌موجود رفع شد (ج.۱) |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | `OK (3 tests, 8 assertions)` |
| ۶.۴ | `phpstan analyse` بدون خطای جدید | | **با baseline اندازه‌گیری شد، نه ادعا:** روی کامیت پیش از تسک ۱۴ خطا در ۹ فایل، و الان **دقیقاً همان ۱۴ خطا در همان ۹ فایل** — صفر خطای جدید. `analyse src/Appointment` تنها → `No errors` |
| ۶.۵ | `npx tsc --noEmit` بدون خطا | | خروجی خالی |
| ۶.۶ | `yarn test` سبز | | `86 files / 604 tests passed`. ⚠️ داخل ddev باینری esbuild پلتفرم اشتباه دارد (محیطی، از قبل)؛ روی host اجرا شد |
| ۶.۷ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | `OK (7 tests, 163 assertions)`. شمارندهٔ inventory با دلیل به‌روز شد |
| ۶.۸ | `docs/api/*` به‌روز شد | | `docs/api/appointment.md` + سند معماری جدید |
| ۶.۹ | چک‌لیست UI (بخش ۳) کامل شد | ✅ | با سه ردیف 🔄 (بررسی چشمی دارک‌مود/فشرده/موبایل انجام **نشد** — استدلالی تأیید شد) و یک ⏳ به‌تعویق‌افتاده |
| ۶.۱۰ | `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | | **`nobat724_front`**: فقط `service_item_uuids[]` را می‌فرستد (`services/response.js:85`، `components/appointment/detail/SubmitData.js:152`)؛ هیچ‌جا `service_item` را نمی‌خواند → افزوده‌های additive نمی‌شکنند. **`clinic-pro-tauri`**: `src/service/response.js` فقط `appointment-settings/weekly-schedule` را صدا می‌زند، هیچ endpoint نوبت/سرویسی → متأثر نیست |
| ۶.۱۱ | commit شد، سپس `graphify update .` | | هر قابلیت **دو کامیت**: کد، سپس گراف جدا (طبق دستور کاربر) |
| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | | ۳.۱۳ (URL state) · بررسی چشمی UI · اصلاح فرمول جمع مدت → تسک ۰۴ |
+17 -2
View File
@@ -15,6 +15,11 @@ use App\Tests\ApiTestCase;
*/
class NumericFieldNormalizerTest extends ApiTestCase
{
private function toPersianDigits(string $latin): string
{
return str_replace(range('0', '9'), ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'], $latin);
}
public function testDigitsHelperTranslatesWithoutStripping(): void
{
self::assertSame('09123456789', PersianText::digits('۰۹۱۲۳۴۵۶۷۸۹'));
@@ -36,11 +41,21 @@ class NumericFieldNormalizerTest extends ApiTestCase
$latinMobile = PersianText::digits($persianMobile);
} while ($this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $latinMobile]) !== null);
// کد ملی هم همین‌طور. ثابت‌بودنش این تست را flaky می‌کرد: در اجرای کامل سوئیت،
// بسته به ترتیب اجرا، ردیفِ اجرای قبلی باعث ۴۲۲ «تکراری» می‌شد و در اجرای تنها
// سبز بود.
$persianNationalCode = str_pad(
$this->toPersianDigits((string) random_int(0, 9_999_999_999)),
10,
'۰',
STR_PAD_LEFT,
);
$this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $persianMobile,
'name' => 'منشی تست',
'national_code' => '۰۰۱۲۳۴۵۶۷۸',
'national_code' => $persianNationalCode,
]);
self::assertSame(201, $this->responseCode(), 'Persian digits must not break validation');
@@ -50,7 +65,7 @@ class NumericFieldNormalizerTest extends ApiTestCase
self::assertNotNull($created, 'user is stored under the latin mobile');
$rel = $this->em->getRepository(DoctorSecretary::class)->findOneBy(['secretary' => $created]);
self::assertSame('0012345678', $rel->getNationalCode());
self::assertSame(PersianText::digits($persianNationalCode), $rel->getNationalCode());
}
public function testNestedArraysAreNormalized(): void