# نکات پیاده‌سازی — تسک ۱۲

## ۱. `book-all` همه یا هیچ

```php
$this->em->wrapInTransaction(function () { /* همهٔ hold ها و confirm ها */ });
```

اگر جلسهٔ ۵ وقت نداشت، جلسات ۱ تا ۴ هم rollback می‌شوند. رزرو نیمه‌کاره یعنی بیمار
پیامک چهار نوبت می‌گیرد، فکر می‌کند دوره‌اش کامل رزرو شده، و چهار ماه بعد می‌فهمد نه.

⚠️ ولی رویدادها (پیامک) با `DispatchAfterCurrentBusStamp` بعد از commit می‌روند (تسک ۰۷)،
پس در حالت rollback هیچ پیامکی نرفته. این وابستگی را جدی بگیر: اگر کسی در تسک ۰۷
`dispatch` را قبل از commit گذاشته باشد، اینجا هشت پیامک اشتباه می‌رود.

## ۲. لنگر متحرک، نه تاریخ ثابت

```php
// ❌ فاصله از شروع دوره
$target = $course->getStartedAt() + $n * $idealDays * 86400;

// ✅ فاصله از جلسهٔ قبلی
$anchor = $slot->start;   // در هر تکرار حلقه به‌روز می‌شود
```

اگر جلسهٔ ۲ چهار روز دیرتر افتاد، جلسهٔ ۳ هم باید چهار روز جابه‌جا شود — وگرنه فاصلهٔ
۲ به ۳ می‌شود ۲۴ روز و از حداقل ۲۱ رد نمی‌شود ولی از نظر درمانی غلط است.

## ۳. لنگر پیشنهاد بعدی: آخرین جلسهٔ **انجام‌شده**

```php
private function lastCompletedAt(TreatmentCourse $course): ?int
{
    // status = completed، نه booked
    return $this->sessionRepo->maxCompletedAt($course);
}
```

اگر از آخرین جلسهٔ `booked` حساب کنی، بیمار که نوبتش را لغو کرد یا نیامد، پیشنهاد بعدی
غلط می‌شود. فقط جلسهٔ واقعاً انجام‌شده لنگر است.

جلسهٔ اول دوره: لنگر `time()` است، یا `started_at`.

## ۴. snapshot پروتکل

چهار فیلد فاصله و `params` هر جلسه کپی می‌شوند. تست:

```php
// tests/Course/ProtocolSnapshotTest.php
$course = $this->starter->start($patient, $service);   // protocol: 8 جلسه، 28 روز
$protocol->setIdealDays(14)->setSessionCount(4);
$this->em->flush();

self::assertSame(28, $course->getIdealDays());
self::assertCount(8, $course->getSessions());
```

قانون پنجم مستند. بدون این، کلینیک که پروتکل را عوض کند، دوره‌های در جریان ۵۰ بیمار
یک‌شبه بی‌معنا می‌شوند.

## ۵. سقف ۹۰ روز و پیام روشن

۸ جلسه × ۲۸ روز = ۲۲۴ روز. جستجوی تسک ۰۶ فقط ۹۰ روز است. پس `book-all` معمولاً
۳ تا ۴ جلسه رزرو می‌کند و بقیه `planned` می‌مانند.

پاسخ باید صریح بگوید:

```json
{
  "booked_count": 3,
  "remaining_planned": 5,
  "message": "۳ جلسهٔ نخست رزرو شد. بقیهٔ جلسات خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند و بعداً قابل رزروند."
}
```

بدون این پیام، کاربر فکر می‌کند سیستم خراب است.

## ۶. `same_as_previous` اجباری نیست

```php
foreach ($preferred as $id) {
    if (in_array($id, $freeIds, true)) return $id;
}
return $this->fallback->pick(…);      // ← نه throw
```

اگر اپراتور جلسهٔ اول مرخصی است، بیمار نباید دو هفته منتظر بماند. ترجیح، نه الزام.
اگر کلینیکی الزام واقعی داشت، آن یک قانون `resource` با `specific_resource` است (تسک ۰۹).

## ۷. تعامل با `spacing` تسک ۰۹

```php
$effectiveMin = max($course->getMinDays(), $this->policies->minDaysFor($ctx) ?? 0);
$effectiveMax = min($course->getMaxDays(), $this->policies->maxDaysFor($ctx) ?? PHP_INT_MAX);
if ($effectiveMin > $effectiveMax) {
    throw new AppException(ErrorCodes::ERR_VALIDATION_001,
        'قوانین کلینیک با پروتکل این دوره سازگار نیستند', 422);
}
```

سخت‌گیرانه‌تر برنده. و اگر ترکیبشان بازهٔ تهی ساخت، خطای روشن — نه جستجوی بی‌نتیجه.

## ۸. اتصال به پکیج

اگر `TreatmentCourse.package` پر باشد، هر `confirm` جلسه یک واحد اعتبار مصرف می‌کند
(تسک ۱۱). `book-all` هشت جلسه یعنی هشت مصرف — پس پیش از شروع:

```php
if ($course->getPackage() !== null) {
    $balance = $this->ledger->balance($course->getPackage());
    if ($balance < count($plannedSessions)) {
        // خطا نیست — هشدار
        $result->addWarning(sprintf('اعتبار پکیج (%d) کمتر از جلسات باقی‌مانده (%d) است', $balance, $count));
    }
}
```

هشدار نه خطا: بیمار می‌تواند بقیه را نقدی بپردازد.

## ۹. edge case ها

| حالت | رفتار درست |
|---|---|
| دورهٔ فعال دوم برای همان سرویس | `422` با uuid دورهٔ موجود در `meta` |
| لغو جلسهٔ وسط دوره | `CourseSession` → `planned`، `appointment_id` → NULL، بقیه دست‌نخورده |
| عدم حضور (`no_show`) در جلسه | `CourseSession` → `skipped`؛ لنگر همان جلسهٔ قبلی می‌ماند |
| جلسهٔ آخر `completed` | دوره → `completed` خودکار + رویداد `CourseCompleted` |
| `abandon` دورهٔ نیمه‌کاره | جلسات `booked` **لغو نمی‌شوند** خودکار — پاسخ شامل تعدادشان و لینک |
| بیمار ۶۰ روز غیبت (> max) | `warning` در پیشنهاد؛ رزرو **مسدود نمی‌شود** |
| پروتکل با `session_count = 1` | `422` — همان نوبت تکی است |
| `params` با مقدار آرایه | `422` — فقط اسکالر |
| حذف پروتکلی که دورهٔ فعال دارد | `422` (FK RESTRICT) — `active=false` مسیر درست |
| دوره روی سرویسی که `bookable=false` شد | جلسات موجود می‌مانند؛ جلسهٔ جدید رزرو نمی‌شود، پیام روشن |

سطر «abandon» عمدی است: لغو خودکار هشت نوبت آیندهٔ بیمار بدون تأیید صریح، عملی
برگشت‌ناپذیر روی داده و ظرفیت کلینیک است. کاربر باید خودش تصمیم بگیرد.

## ۱۰. تست

```
tests/Course/CourseStarterTest.php
  - ۸ جلسهٔ planned با params درست
  - سرویس بدون پروتکل → 422
  - دورهٔ فعال دوم → 422 با meta
tests/Course/ProtocolSnapshotTest.php            ← ⭐ قانون پنجم
tests/Course/CourseSchedulerTest.php             ← ⭐
  - book-all: لنگر متحرک (فاصله از جلسهٔ قبلی، نه از شروع)
  - نزدیک‌ترین به ایده‌آل انتخاب می‌شود، نه اولین
  - شکست جلسهٔ N → rollback همهٔ ۱..N-1
  - سقف ۹۰ روز → جلسات باقی planned + پیام
tests/Course/NextSuggestionTest.php
  - لنگر = آخرین completed، نه booked
  - عبور از max → warning
tests/Course/CourseProgressTest.php
  - completed/total/next_session_number/next_params
tests/Course/SameResourcePreferenceTest.php
  - منبع جلسهٔ اول ترجیح داده می‌شود
  - منبع مشغول → fallback به least_gap، بدون خطا
tests/Course/CoursePolicyInteractionTest.php
  - قانون سخت‌گیرانه‌تر برنده
  - بازهٔ تهی → 422 روشن
tests/Course/CoursePackageTest.php
  - هر جلسه یک واحد مصرف
  - اعتبار کمتر از جلسات → warning نه error
tests/Course/CourseLifecycleTest.php
  - لغو وسط دوره · no_show → skipped · جلسهٔ آخر → completed خودکار
  - abandon نوبت‌های booked را لغو نمی‌کند
```

## ۱۱. مستندات

`docs/api/course.md` بساز. حتماً بنویس: قاعدهٔ «سخت‌گیرانه‌تر برنده» بین پروتکل و قانون،
رفتار سقف ۹۰ روز، و اینکه `abandon` نوبت‌ها را لغو نمی‌کند.
