diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 7ebe1fec..26ca89c6 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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[]` | 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": { "": 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: } }` |------|-------------| | 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": { "": 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 هم نیست. + --- diff --git a/docs/architecture/booking-modes.md b/docs/architecture/booking-modes.md new file mode 100644 index 00000000..b614fd3c --- /dev/null +++ b/docs/architecture/booking-modes.md @@ -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` را اضافه می‌کند: برنامهٔ چندبخشی روی چند منبع +(اتاق، دستگاه، اپراتور). ارتقا **یک‌طرفه** خواهد بود و مشروط بر نبودِ نوبت فعال آینده. +تا آن زمان، این سند دو حالت دارد و همین ماتریس معتبر است. diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index 9ca2af2e..e0f8a303 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -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 · اصلاح فرمول جمع مدت → تسک ۰۴ | diff --git a/tests/Shared/NumericFieldNormalizerTest.php b/tests/Shared/NumericFieldNormalizerTest.php index b47a1fd5..3fcb2dcd 100644 --- a/tests/Shared/NumericFieldNormalizerTest.php +++ b/tests/Shared/NumericFieldNormalizerTest.php @@ -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