In service mode the page now mounts the existing ServiceSlotPicker and hides the three free-form time inputs plus the single-service select: a 45-minute service could previously be shortened to 20 and the next patient would sit on top of it. Hidden rather than disabled — a disabled field reads as "you must do something here". Saving splits in two: the service-aware endpoint takes the time and services (the client sends no duration), then the usual PATCH carries deposit, insurance, status and note without slot_start/slot_end/version, since the reschedule already advanced the optimistic-lock version. Booking mode is read from the appointment's own schedule via an explicit clinic_uuid, not from the panel's current environment: a doctor can be slot-based in their office and service-based in a clinic. That required exposing clinic_uuid in Appointment::toArray(), which was missing. appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of that appointment — an ungated parameter would let anyone fabricate availability. ServiceSlotPicker gained two optional props; its existing callers pass neither and are unaffected. Its reset-on-doctor-change effect now skips the first run, which would otherwise wipe the initial selection. 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>
21 KiB
21 KiB
چکلیست — تسک ۰۰ (تکمیل نوبتدهی سرویسی در clinicpro)
وضعیت کلی: 🔄 در حال انجام — قابلیت ۷ از ۱۰ تمام شد (مانده: ReserveAppointmentsPage، backfill، مستندات) آخرین بازبینی: ۱۴۰۵/۰۵/۰۸
قواعد: _shared/definition-of-done.md · خط سرخها: _shared/red-lines.md · UI: _shared/ui-conventions.md
۰. خط سرخ — منطق اسلاتی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | SlotModeFrozenTest + سه fixture ساخته شد پیش از هر تغییر کد |
✅ | tests/Appointment/SlotModeFrozenTest.php + tests/Appointment/fixtures/ — هیچ کد تولیدیای هنوز لمس نشده |
| ۰.۲ | time() |
✅ | انحراف عمدی از متن تسک. تاریخ ثابتِ گذشته را 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). یادداشت قبلی چکلیست غلط بود |
| ۰.۸ | 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-reservePATCH |
✅ | 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 نشان میدهد |
✅ | روش نوبتدهی از برنامهٔ همین نوبت پرسیده میشود (clinic_uuid صریح)، نه از محیط جاری پنل — پزشک میتواند در مطب اسلاتی و در کلینیک سرویسی باشد. لازمهٔ این کار: افزودن clinic_uuid به toArray() که نبود |
| ۳.۲ | AppointmentEditPage: حالت اسلاتی دقیقاً رفتار امروز |
✅ | ۵ تست موجود صفحه سبز ماند + دو تست خط سرخ تازه: سه فیلد ساعت با مقدار هیدریتشده، و همان PATCH با version بدون هیچ POST |
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی پنهان، نه disabled | ✅ | queryByLabelText('ساعت شروع') در حالت سرویسی null است. SearchableSelect سرویس تکی هم پنهان میشود — نگهداشتنش دو منبع برای یک چیز بود |
| ۳.۴ | ServiceSlotPicker موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد |
✅ | دو prop اختیاری: excludeAppointmentUuid و initialSelection. رفتار فعلیاش در AppointmentCreatePage و AppointmentsPage دستنخورده (هر دو prop undefined). یک اصلاح لازم شد: effect ریست روی تعویض پزشک، اجرای نخست را معاف کند وگرنه initialSelection همان لحظه پاک میشد |
| ۳.۵ | ReserveAppointmentsPage: سرویسها + دکمهٔ تبدیل |
⏳ | |
| ۳.۶ | ReserveAppointmentsPage از جدول خام به DataTable مهاجرت کرد |
⏳ | <td style={td}> حذف شد |
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | 🔄 | در AppointmentEditPage هست («زمان انتخابی: ۱۶:۰۰ (+۱۰ دقیقه فاصله)») و ServiceSlotPicker خودش «مدت کل: N دقیقه» را نشان میدهد. ReserveAppointmentsPage مانده — قابلیت ۸ |
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکنهای styles.css |
⏳ | |
| ۳.۹ | دارکمود (data-theme="dark") بررسی شد |
⏳ | |
| ۳.۱۰ | حالت فشرده (data-density="compact") بررسی شد |
⏳ | |
| ۳.۱۱ | انتخاب چند سرویس با SearchableSelect؛ هیچ <select> بومی |
✅ | ServiceSlotPicker از SearchableSelect برای بخش و دکمههای toggle برای سرویسها استفاده میکند (کد موجود). هیچ <select> بومی اضافه نشد |
| ۳.۱۲ | backTo/BackButton روی هر دو صفحه |
⏳ | |
| ۳.۱۳ | وضعیت لیست رزروها در URL با useUrlState |
⏳ | |
| ۳.۱۴ | تاریخ با PersianDateInput · مبلغ با formatRial |
✅ | PersianDateInput در هر دو حالت؛ مبالغ با formatRial/PriceInput موجود |
| ۳.۱۵ | RTL بررسی شد (ms/me نه ml/mr) |
⏳ | |
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | |
| ۳.۱۷ | همهٔ رشتهها فارسی و از i18n | ⏳ | |
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ✅ | useDoctorBookingServices (موجود) + useQuery داخل picker؛ استخراج data?.data |
۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | SlotModeFrozenTest — سه سنجه |
✅ | OK (3 tests, 8 assertions) |
| ۴.۲ | 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 موجود سبز ماند |
⏳ | |
| ۴.۷ | BookingTenantTest موجود سبز ماند |
✅ | داخل tests/Appointment — کل ۳۰۹ تست tests/Appointment + tests/Shared سبز |
| ۴.۱۰ | ServiceSlotExcludeSelfTest — رفتار exclude |
✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمیگردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال میماند · null صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بیاثر |
| ۴.۹ | AppointmentServiceFieldsTest — متدها و ستونهای جدید |
✅ | ۹ تست / ۲۴ assertion. شامل: همگامی ستون تکی · حفظ ترتیب · فهرست خالی → null · حالت اسلاتی هر دو ستون null · مدتِ null بافر را هم null میکند · تکراریها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
| ۴.۸ | AppointmentEditPage — دو حالت |
✅ | فایل جدید AppointmentEditPage.serviceMode.test.tsx: ۹ تست. شامل: پنهانبودن ورودی ساعت · دکمهٔ غیرفعال + راهنما · exclude_appointment_uuid در query · service-reschedule بدون هیچ مدتی · PATCH بعدی بیversion · هشدار سرویس غیرفعال · و سه تست خط سرخ (اسلاتی و رزرو). فایل موجود AppointmentEditPage.test.tsx هم سبز ماند |
| ۴.۱۱ | کل vitest سبز | ✅ | 86 files / 604 tests passed روی host (داخل ddev باینری esbuild پلتفرم اشتباه دارد — مسئلهٔ محیطی از قبل) |
۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | docs/api/appointment.md — دو endpoint جدید + توسعهٔ PATCH |
⏳ | |
| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | ⏳ | |
| ۵.۳ | docs/architecture/booking-modes.md ساخته شد |
⏳ | تسک ۰۶ حالت سوم را اضافه میکند |
| ۵.۴ | دو کد خطای جدید مستند شد | ⏳ |
۵.۵ یافتههای جانبی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ج.۱ | یک شکست flaky در اجرای ترکیبی tests/Appointment tests/Shared |
⚠️ | یک بار ۱ failure دید، سه اجرای بعدی سبز (۳۲۴ تست). نام تست ثبت نشد چون خروجی از دست رفت. بازتولید نشد — بدهی ثبتشده، نه «حلشده». احتمال: برخورد شمارهٔ موبایل تصادفی در ApiTestCase::createUser روی db_test که هرگز ریست نمیشود (خودِ کلاس این را مستند کرده) |
| ج.۲ | یک PHPUnit Notice در tests/Shared |
⚠️ | پیش از تغییرات این تسک هم بود (baseline). خارج از دامنهٔ این تسک |
| ج.۳ | db_test تاریخچهٔ migration جدا دارد |
⚠️ | doctrine:migrations:migrate روی آن میشکند (Table users already exists)؛ ستونهای جدید با ALTER دستی اضافه شدند. برای تسکهای بعدی هم همین لازم است |
۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | همهٔ ردیفهای بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بیدلیل) | ⏳ | |
| ۶.۲ | 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 . |
⏳ | |
| ۶.۱۲ | موارد بهتعویقافتاده با دلیل و تسک مقصد ثبت شدند | ⏳ |