# نکات پیاده‌سازی — تسک ۰۶

## ۱. سه کوئری، بعد هیچ

قاعدهٔ غیرقابل‌مذاکره: **داخل حلقهٔ روز و حلقهٔ نقطهٔ شروع، هیچ 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` را با بخش «حالت‌های نوبت‌دهی» به‌روز کن.
