# چکلیست — تسک ۰۰ (تکمیل نوبتدهی سرویسی در clinicpro) **وضعیت کلی:** 🔄 در حال انجام — قابلیت ۶ از ۱۰ تمام شد (بکاند کامل؛ مانده: UI، backfill، مستندات) **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · خط سرخها: [_shared/red-lines.md](../_shared/red-lines.md) · UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) --- ## ۰. خط سرخ — منطق اسلاتی | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ✅ | `tests/Appointment/SlotModeFrozenTest.php` + `tests/Appointment/fixtures/` — هیچ کد تولیدیای هنوز لمس نشده | | ۰.۲ | ~~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}` دستنخورده | ⏳ | در پایان تسک تأیید میشود | | ۰.۷ | `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 را حذف کرده) | | ۰.۱۰ | baseline: کل `tests/Appointment` پیش از تغییرات سبز | ✅ | `OK (166 tests, 382 assertions)` | ## ۱. بکاند | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۱.۱ | `ServiceBookingCalculator` ساخته شد | ✅ | `src/Appointment/Service/ServiceBookingCalculator.php` + VO در `src/Appointment/ValueObject/ServiceBookingDuration.php` (قرارداد پروژه `ValueObject` است نه `Dto` — `src/Billing/ValueObject`، `src/Insurance/ValueObject`) | | ۱.۲ | `serviceSlots()` موجود از آن استفاده میکند، **خروجیاش عوض نشده** | ✅ | ۲۰ خط از کنترلر حذف شد. بیتبهبیتبودن: `error()` و `ExceptionSubscriber` **یک envelope یکسان** میسازند (`['success'=>false,'data'=>null,'errors'=>[{code,message,field?}]]`)، پس `AppException` جای `return $this->error()` نشست. شاهد: ۱۶۶ تست موجود سبز ماند، شامل `ServiceBasedSlotsTest` و `ServiceModeSectionDurationTest` که همین مسیرهای خطا را میسنجند | | ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ✅ | با کامنت صریح در docblock کلاس: «تسک ۰۴ همانجا یک خط عوض میکند» | | ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ✅ | route ثبت شد (`app_appointment_appointment_servicereschedule`). کنترلر نازک: فقط ۴۰۴/۴۰۳/اعتبار `start` و ترجمهٔ دو استثنای همزمانی؛ منطق در سرویس. `forManagement` از `canManageContext()` میآید نه از `canManage()` — بیمارِ صاحب نوبت باید پنجرهٔ رزرو را رعایت کند | | ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ✅ | سرویسها **پیش از** بلوک زمان حل میشوند چون مدت به آنها وابسته است. بررسی مدت فقط وقتی `$movingToLiveSlot` است (نوبت رزرو معاف — `slot_start == slot_end`). در حالت اسلاتی `$duration === null` میماند و هیچ خطی اجرا نمیشود | | ۱.۶ | `PATCH` مقدار `service_item_uuids[]` میپذیرد | ✅ | وقتی فهرست کامل آمد، `service_item_uuid` تکی در حلقهٔ relations **نادیده** گرفته میشود (`continue`) — دو منبع برای یک چیز به نوبتِ ناسازگار میرسد. `allowInactive` فقط وقتی فهرست **نیامده** روشن است: نوبت موجود قفل نشود ولی افزودن سرویس غیرفعال رد شود | | ۱.۷ | ~~`ReserveConversionService` + `POST /convert-reserve`~~ → توسعهٔ `PATCH` | ✅ | **endpoint جدید ساخته نشد و توجیه نداشت** (قاعدهٔ #۸ پروژه). `PATCH` از قبل رزرو→زماندار را دارد: `rescheduleTo($start,$end,$isReserve)` که خودش `refreshActiveSlotKey()` را صدا میزند. تست `testReserveConvertsToATimedAppointmentThroughPatch` این را میسنجد | | ۱.۸ | نوبت رزرو در حالت سرویسی سرویسها را ذخیره میکند | ✅ | سرویسها + `service_total_minutes` روی رزرو ذخیره میشوند تا تبدیل بعدی مدت را از دست ندهد؛ بررسی مدت روی خودِ رزرو اعمال نمیشود | | ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ✅ | همان نام/جای/پیشفرضِ `isSlotTaken($doctor,$start,$end,?int $excludeId=null)`. در repository به `occupiedIntervals()` هم پاس داده میشود، پس `findBlockingIntervals` بیتغییر ماند | | ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ میکند و برای نوبت قدیمیِ فقط-تکی هم uuid میدهد | | ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را همگام میکند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست میکند، این یکی **بیقید** بازنویسی میکند (و با فهرست خالی `null` میشود). چهار مصرفکننده روی `service_item` تکی خواندهاند | | ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا میزند | | ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ✅ | `assertStartIsOffered()`. تست `testTimeOutsideTheOfferedListIsRejected` با ۰۳:۰۰ (بیرون شیفت ۰۹–۱۸) میسنجد — `isSlotTaken` تنها این را نمیگرفت | | ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احرازشده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمیشمارد** و از تور ایمنی فرار میکند | | ۱.۱۵ | `allowInactive` فقط برای سرویسهای موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعالبودن → `warnings[]` بهجای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد میشود (تست دارد) | | ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ✅ | `serviceSlots()` از ۶۰ خط به ۴۰ خط؛ هیچ حلقهٔ محاسباتی در کنترلر نماند | ## ۲. دیتابیس و مهاجرت | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهیپذیر) | ✅ | `SMALLINT DEFAULT NULL` هر دو · `migrations/Version20260730091332.php` · `setServiceDuration()` هر دو را با هم ست میکند (بافرِ بیمدت معنا ندارد) | | ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ✅ | تأیید با `SHOW COLUMNS`: `slot_start` int NOT NULL · `slot_end` int NOT NULL · `active_slot_key` varchar(64) UNI · `is_reserve` tinyint default 0 — همه دستنخورده | | ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ✅ | `ERR_APPOINTMENT_003` (مدت نمیخواند) و `ERR_APPOINTMENT_004` (روش نوبتدهی ناسازگار) — **نه `_010`/`_011`** که متن تسک حدس زده بود؛ فایل فقط تا `_002` داشت | | ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیشفرض، idempotent | ⏳ | | | ۲.۵ | backfill مقدار را از خود نوبت میگیرد، نه بازمحاسبه از سرویسها | ⏳ | | | ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ✅ | dev با migration؛ **db_test با `ALTER` دستی** — تاریخچهٔ migration آن DB از dev جدا است (`Table users already exists`) و این روش نگهداشتِ موجودِ همان DB است | ## ۳. UI — پنل ادمین | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان میدهد | ⏳ | | | ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت | | ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | | | ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` | | ۳.۵ | `ReserveAppointmentsPage`: سرویسها + دکمهٔ تبدیل | ⏳ | | | ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `