# نکات پیاده‌سازی — تسک ۰۶ ## ۱. سه کوئری، بعد هیچ قاعدهٔ غیرقابل‌مذاکره: **داخل حلقهٔ روز و حلقهٔ نقطهٔ شروع، هیچ I/O نیست.** ```php // ❌ مرگ کارایی foreach ($days as $day) { foreach ($starts as $start) { if ($this->occupancyRepo->isFree($resource, $start, $end)) { … } // ← N×M کوئری } } // ✅ $index = OccupancyIndex::build($this->occupancyRepo->findForResources($ids, $from, $to), $windows); foreach ($starts as $start) { $index->hasRoom($id, $start, $end, 1); } ``` `AvailabilityPerformanceTest` تعداد کوئری را قفل می‌کند تا اولین refactor این را نشکند. ## ۲. `setup/cleanup` — بازهٔ اشغال، نه بازهٔ بخش ```php $occStart = $segmentStart - $resource->getSetupMinutes() * 60; $occEnd = $segmentEnd + $resource->getCleanupMinutes() * 60; $index->hasRoom($resourceId, $occStart, $occEnd, $units); ``` نکتهٔ ظریف: `setup/cleanup` per **منبع** است، ولی منبع در لحظهٔ بررسی هنوز انتخاب نشده. پس دو گذر: 1. بررسی اولیه با **بیشینهٔ** `setup/cleanup` کاندیدها (محافظه‌کار) 2. بعد از انتخاب منبع، بازهٔ دقیق همان منبع محاسبه و دوباره بررسی شود گذر دوم ارزان است (یک منبع، یک بازه) و از رد شدن اشتباه کاندیدها جلوگیری می‌کند. ## ۳. `capacity` و `units` ``` exclusive → units = capacity (منبع کامل) shared → units = 1 passive → units = capacity (رزرو است، ولی پرچم passive برای گزارش) ``` `hasRoom` جمع `units` اشغال‌های متداخل را با `capacity` مقایسه می‌کند. با این مدل، اتاق تزریق سه‌تخته با یک ردیف کار می‌کند و شمارش خودکار است. ## ۴. منبع مشترک بین بخش‌ها مثال مستند: اپراتور در بخش ۱ (۰-۵) و بخش ۳ (۳۵-۵۵) لازم است، در بخش ۲ نه. - **باید همان اپراتور باشد** → `groupKey` در `ResourceAllocator` - **در بخش ۲ نباید اشغال بماند** → دو ردیف اشغال جدا، نه یکی از ۰ تا ۵۵ اگر ردیف را یکی کنی، کل ارزش این پروژه از بین می‌رود: همان ۳۰ دقیقه‌ای که می‌خواستیم آزاد کنیم دوباره قفل می‌شود. تست پذیرش «آزادسازی ظرفیت» دقیقاً همین را می‌سنجد. ## ۵. مسیر قدیمی دست‌نخورده `SlotCalculatorService` **هیچ تغییری نمی‌کند**. `AvailabilityEngine` یک کلاس جدید کنارش است. انتخاب بین این دو فقط در کنترلر و بر اساس `booking_mode`: ```php $mode = $schedule?->getMeta()['booking_mode'] ?? WeeklySchedule::MODE_SLOT; return match ($mode) { WeeklySchedule::MODE_RESOURCE => $this->availabilityEngine->search($req), WeeklySchedule::MODE_SERVICE => $this->slotCalculator->getServiceStartTimes(…), // بدون تغییر default => $this->slotCalculator->getAvailableSlots(…), // بدون تغییر }; ``` هر endpoint فقط حالت خودش را می‌پذیرد و بقیه را با `ERR_WRONG_BOOKING_MODE` رد می‌کند — نه fallback خاموش. fallback خاموش یعنی کلینیکی که فکر می‌کند حالت جدید دارد، بی‌صدا روی حالت قدیم کار می‌کند و هیچ‌کس نمی‌فهمد چرا ظرفیتش باز نشد. ## ۶. ارتقای حالت — یک‌طرفه و با شرط ``` POST /api/v1/appointment-settings/upgrade-booking-mode { "schedule_uuid": "…", "confirm": true } شرایط: - حالت فعلی slot یا service باشد - هیچ نوبت pending/confirmed آینده‌ای وجود نداشته باشد - حداقل یک منبع فعال در شعبه باشد - سرویس‌های bookable حداقل یک SegmentTemplate یا duration معتبر داشته باشند بازگشت به حالت قبلی: ممنوع (پاسخ 422) ``` دلیل ممنوعیت بازگشت: نوبت‌های ثبت‌شده در حالت `resource` بخش و اشغال چندمنبعی دارند و مدل قدیمی نمی‌تواند نمایششان دهد. ## ۷. سقف‌ها و پیش‌فرض‌ها | پارامتر | پیش‌فرض | سقف | |---|---|---| | بازهٔ جستجو | ۳۰ روز | ۹۰ روز (مستند بند ۱۰) | | `limit` نتایج | ۵۰ | ۲۰۰ | | گام کاندید | ۱۵ دقیقه | حداقل ۵ | | منابع کاندید per نیازمندی | — | ۵۰ (بیشتر → `422` با پیشنهاد استفاده از استخر) | ## ۸. edge case ها | حالت | رفتار درست | |---|---| | هیچ نتیجه‌ای در بازه | `data: []` + `reason` (`no_resource`, `fully_booked`, `no_calendar`) — نه ۴۰۴ | | نقطهٔ شروع دقیقاً روی لبهٔ پنجرهٔ آزاد | معتبر — بازه‌ها نیم‌باز `[s, e)` | | نوبتی که تازه لغو شده | با کش پنجره‌ای تداخل ندارد چون اشغال کش نمی‌شود | | `hold` منقضی‌شده در `resource_occupancy` | در کوئری `WHERE status='hold' AND expires_at > :now` رد شود | | برنامهٔ ۶۰ دقیقه‌ای و پنجرهٔ آزاد ۵۹ دقیقه | هیچ کاندیدی — هرس گام ۳ | | منبعِ استخری که وسط بازه غیرفعال شده | `findEligible` فقط `active=true` می‌دهد؛ اشغال‌های قبلی‌اش می‌مانند | | دو نیازمندی هم‌شکل با `count=1` در یک بخش | `groupKey` یکسان → همان منبع دوبار انتخاب می‌شود ← **باگ**. `count=2` بنویس یا `groupKey` را با اندیس نیازمندی درون همان بخش متمایز کن | | تغییر ساعت رسمی (تغییر ساعت تابستانی) | ایران از ۱۴۰۱ ندارد؛ ولی محاسبات با timestamp انجام شود نه ساعت محلی | سطر ماقبل آخر یک تلهٔ واقعی است: `groupKey` باید بین **بخش‌ها** یکی باشد ولی درون یک بخش، دو نیازمندی مجزا دو منبع بگیرند. کلید = `(role, skills, constraints, indexInSegment)` و تطبیق بین‌بخشی روی سه جزء اول. ## ۹. تست ``` tests/Appointment/Availability/OccupancyIndexTest.php ← واحد، بدون DB - capacity=3 با ۲ اشغال → جا دارد؛ با ۳ → ندارد - بازهٔ مماس (end == start) → تداخل نیست - shared vs exclusive tests/Appointment/Availability/CandidateGeneratorTest.php - هرس با تنگ‌ترین منبع - نقطهٔ گذشته حذف - برنامه‌ای که در پنجره جا نمی‌شود → هیچ کاندید tests/Appointment/Availability/ResourceAllocatorTest.php - منبع مشترک بین بخش ۱ و ۳ → یک نفر - دو نیازمندی هم‌شکل در یک بخش → دو منبع - تخصیص ناموفق → null، نه استثنا tests/Appointment/Availability/CapacityReleaseTest.php ← ⭐ تست پذیرش اصلی - نوبت الف ۱۰:۰۰-۱۱:۰۰، اپراتور فقط ۱۰:۰۰-۱۰:۰۵ و ۱۰:۳۵-۱۱:۰۰ - جستجوی بیمار ب → زمانی در ۱۰:۰۵-۱۰:۳۵ پیدا شود tests/Appointment/Availability/StrategyTest.php - least_gap کمترین شکاف را می‌سازد - balanced کم‌کارترین را می‌دهد - preserve_specialists کمترین level کافی را می‌دهد tests/Appointment/Availability/BookingModeGuardTest.php - حالت slot روی endpoint جدید → 422 ERR_WRONG_BOOKING_MODE - endpoint قدیمی در حالت resource → 422 - ارتقا با نوبت فعال آینده → 422 tests/Appointment/AvailabilityPerformanceTest.php - < 500ms و <= 5 کوئری tests/Appointment/LegacyBookingUnchangedTest.php - همهٔ تست‌های موجود appointment-slots و appointment-service-slots سبز بمانند ``` ## ۱۰. مستندات `docs/api/appointment-availability.md` بساز — شامل جدول استراتژی‌ها، توضیح تخصیص حریصانه و محدودیتش، و ماتریس «کدام endpoint در کدام حالت کار می‌کند». `docs/api/appointment.md` را با بخش «حالت‌های نوبت‌دهی» به‌روز کن.