# نکات پیاده‌سازی — تسک ۰۰ ## ۱. اول تست خط سرخ، بعد هر چیز دیگر ترتیب کار: ``` ۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture ← اول این ۲. ddev exec php bin/phpunit --group=slot-mode-frozen ← باید سبز باشد قبل از هر تغییری ۳. بقیهٔ تسک ۴. دوباره گام ۲ — باید همچنان سبز باشد ``` اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکرده‌ای — snapshot وضعیت تغییریافته را گرفته‌ای. ## ۲. `isServiceMode` گِیت همه‌چیز است هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد: ```php if ($this->serviceCalc->isServiceMode($doctor, $clinic)) { // … منطق جدید } ``` نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است. نوبت اسلاتی هم می‌تواند `service_item_id` داشته باشد (فیلدهای Figma نوبت‌ها) — آن دلیل سرویسی بودن نیست. اشتباه رایج: ```php // ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید می‌کند if ($appointment->getServiceItems()->count() > 0) { … } ``` ## ۳. جمع سادهٔ مدت را همین‌جا اصلاح نکن ```php $totalMinutes += $duration; // ← اشتباه است، ولی دست نزن ``` مستند بند ۵ می‌گوید این فرمول ظرفیت را الکی پر می‌کند و راه‌حلش «زمان تنها / زمان اضافه» است — که تسک ۰۴ می‌سازد. اصلاحش اینجا یعنی: - مدت همهٔ نوبت‌های چندسرویسیِ در حال رزرو یک‌شبه کم می‌شود - سایت و اپ دسکتاپ عدد متفاوت می‌بینند بدون اینکه چیزی در build بشکند - و هیچ داده‌ای برای «زمان اضافه» وجود ندارد، پس اصلاح بی‌ورودی غیرممکن است کاری که این تسک می‌کند: محاسبه را به **یک نقطه** منتقل می‌کند تا تسک ۰۴ یک خط عوض کند. ## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن `AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان پیش‌فرض `null`. دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`) یعنی اولین کسی که هر دو را می‌بیند یکی را اشتباه صدا می‌زند. ## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن» ```php // ❌ ناکافی if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ } // ✅ $starts = $this->slotCalculator->getServiceStartTimes(…); if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ } ``` `isSlotTaken` فقط تداخل با نوبت دیگر را می‌گوید. `getServiceStartTimes` علاوه بر آن شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط اول، منشی می‌تواند نوبت را ساعت ۳ بامداد بگذارد. ## ۶. `warnings[]` به‌جای `422` برای سرویس غیرفعال در نوبت موجود ```php $duration = $this->serviceCalc->calculate(…, allowInactive: true); // $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبت‌دهی فعال نیست'] ``` نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابه‌جایی و لغو بماند. `422` یعنی آن نوبت برای همیشه قفل می‌شود و منشی هیچ کاری نمی‌تواند بکند. ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای uuid های موجودِ نوبت `true` می‌شود، نه برای uuid های تازه‌ی درخواست. ## ۷. `replaceServiceItems` باید `serviceItem` تکی را هم‌گام کند ```php $this->serviceItem = $items[0] ?? null; ``` چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`، `ReserveAppointmentsPage`، `nobat724_front/services/response.js`، `clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که `ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن. ## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)` ```php public function setIsReserve(bool $v): self { $this->isReserve = $v; $this->refreshActiveSlotKey(); // ← اگر نیست، اضافه کن $this->updatedAt = time(); return $this; } ``` بدون آن، رزروِ تبدیل‌شده `active_slot_key = NULL` می‌ماند و دو نفر می‌توانند همان ساعت را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط موجود را اعمال می‌کند، نه عوضش می‌کند. تست: `ConvertReserveSlotKeyTest`. ## ۹. تبدیل رزرو، اتمی ```php $this->em->wrapInTransaction(function () use ($reserve, $req) { $duration = …; // یا اسلات اسلاتی $reserve->setIsReserve(false); $reserve->reschedule($req->start, $end); $reserve->replaceServiceItems($duration->serviceItems); // UniqueConstraintViolationException روی active_slot_key → 409 }); ``` `try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN` — همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام می‌دهد. از همان استفاده کن. ## ۱۰. `ReserveAppointmentsPage` → `DataTable` صفحه امروز جدول خام با `` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به `DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md) است و در دارک‌مود می‌شکند. این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دست‌خورده باید با دیزاین‌سیستم بخواند. ## ۱۱. edge case ها | حالت | رفتار درست | |---|---| | نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمی‌شود** | | `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز | | `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` | | `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است | | زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست می‌آید | | بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابه‌جایی جدید بافر جدید می‌گیرد | | سرویس محیط دیگر در `service_item_uuids[]` | `404` — `TenantOwnershipChecker` **پیش از** هر بررسی دیگر | | `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) | | نوبت گذشته | `service-reschedule` → `422` | | دو درخواست جابه‌جایی هم‌زمان به یک زمان | `active_slot_key` → یکی `409` | | نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمی‌دهد؛ ولی اگر داده دستی عوض شد → `422` روشن | ## ۱۲. تست ``` tests/Appointment/SlotModeFrozenTest.php ← ⭐ اول از همه - قرارداد appointment-slots بیت‌به‌بیت - قرارداد month-availability - امضای متدهای عمومی SlotCalculatorService tests/Appointment/ServiceBookingCalculatorTest.php - جمع مدت چند سرویس (رفتار فعلی حفظ شود) - override منشی - سرویس بدون مدت → 422 - سرویس غیرفعال با allowInactive → warning نه خطا - سرویس محیط دیگر → استثنا، بدون افشای وجود tests/Appointment/ServiceRescheduleTest.php ← ⭐ - جابه‌جایی با همان سرویس‌ها → مدت یکسان - حذف یک سرویس → مدت خودکار کم می‌شود، کلاینت عددی نفرستاده - زمان بیرون getServiceStartTimes → 422 - زمان فعلی خود نوبت با excludeAppointmentId در فهرست است - روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE tests/Appointment/PatchServiceDurationTest.php - slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام - service_item_uuids[] جایگزینی کامل می‌کند و serviceItem تکی هم‌گام می‌شود - در حالت اسلاتی هیچ‌کدام از این‌ها اجرا نمی‌شود (رفتار امروز) tests/Appointment/ConvertReserveTest.php - رزرو سرویسی → نوبت زمان‌دار با مدت درست - رزرو اسلاتی → مسیر اسلاتی، بدون تغییر - active_slot_key بعد از تبدیل پر می‌شود - دو تبدیل هم‌زمان → یکی 409 tests/Appointment/ServiceModeSectionDurationTest.php ← موجود، باید سبز بماند tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند assets/admin/pages/AppointmentEditPage.test.tsx - حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست - حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است - تغییر سرویس‌ها، slot انتخابی را باطل می‌کند ``` ## ۱۳. مستندات `docs/api/appointment.md`: - بخش «روش‌های نوبت‌دهی» با ماتریس «کدام endpoint در کدام حالت» - دو endpoint جدید - توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid` - دو کد خطای جدید `docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت. و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند — تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند.