# Course — دورهٔ درمان

اندپوینت‌های `src/Course/*`. «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی
می‌شناخت، در حالی که دوره حالت اصلی کسب‌وکار است.

همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر).

---

## مفاهیم

| | چیست |
|---|---|
| `CourseProtocol` | تعریف دوره per سرویس: تعداد جلسه و سه فاصلهٔ حداقل/ایده‌آل/حداکثر |
| `CourseProtocolStep` | پارامتر هر جلسه (مثلاً سطح انرژی) — اسکالر و آزاد |
| `TreatmentCourse` | دورهٔ یک بیمار؛ چهار عدد پروتکل را **کپی** می‌کند |
| `CourseSession` | جلسات دوره: `planned` → `booked` → `completed` (یا `skipped`) |

**سه فاصله سه معنا دارند:** `min` زودترین زمان مجاز، `ideal` بهترین، `max` جایی که دیرتر
از آن اثر دوره افت می‌کند. برنامه‌ریز **نزدیک‌ترین وقت به ایده‌آل** را می‌گیرد، نه اولین
وقت خالی: روز ۲۱ (حداقل) از نظر درمانی بدتر از روز ۲۷ است.

**لنگر متحرک:** فاصله همیشه از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. اگر
جلسهٔ ۲ سه روز دیرتر افتاد، جلسهٔ ۳ هم جابه‌جا می‌شود.

**snapshot:** تعداد جلسه، سه فاصله و پارامتر هر جلسه در لحظهٔ شروع کپی می‌شوند. تغییر
پروتکل فردا، دورهٔ در جریان را عوض نمی‌کند (قانون پنجم مستند).

**یک دورهٔ فعال per (بیمار، سرویس):** با ستون `active_course_key` که در حالت‌های
`completed`/`abandoned` تهی می‌شود — همان الگوی `Appointment::activeSlotKey`، چون MariaDB
کلید یکتای جزئی ندارد.

---

## GET · POST `/api/v1/course-protocols`

### Request Body (POST)
```json
{
  "service_uuid": "acea173f-aa5d-4d1e-bc19-9b5ca7e66234",
  "session_count": 8,
  "min_days": 21,
  "ideal_days": 28,
  "max_days": 45,
  "prefer_same_resource": true,
  "steps": [
    { "session_number": 1, "params": { "energy": 12 } },
    { "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": 45 }
  ]
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `service_uuid` | string | ✅ | یک پروتکل per سرویس |
| `session_count` | int | ✅ | حداقل ۲ — دورهٔ یک‌جلسه‌ای همان نوبت تکی است |
| `min_days` / `ideal_days` / `max_days` | int | ✅ | باید `min ≤ ideal ≤ max` |
| `prefer_same_resource` | bool | — | پیش‌فرض `true` |
| `steps` | array | — | جایگزینی کامل؛ `params` فقط اسکالر |

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "6be5047e-0a3e-4c55-a637-77b814b3b8e3",
    "service_uuid": "acea173f-…",
    "service_name": "لیزر فول‌بادی",
    "session_count": 8,
    "min_days": 21,
    "ideal_days": 28,
    "max_days": 45,
    "prefer_same_resource": true,
    "active": true,
    "steps": [
      { "session_number": 1, "params": { "energy": 12 }, "override_duration_minutes": null },
      { "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": null }
    ],
    "created_at": 1785484481
  }
}
```

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | `session_count < 2`، ترتیب فاصله‌ها نادرست، سرویسی که از قبل پروتکل دارد، یا `session_number` بیرون بازه |
| `ERR_VALIDATION_002` | 422 | `service_uuid` غایب |
| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری |

`PATCH /api/v1/course-protocol/{uuid}` همان فیلدها؛ `DELETE` پروتکل را **غیرفعال** می‌کند
چون دوره‌های در جریان به آن ارجاع دارند.

---

## POST `/api/v1/treatment-course`

شروع دوره. **همهٔ** جلسات همان لحظه با وضعیت `planned` ساخته می‌شوند تا بیمار از روز اول
ببیند «۸ جلسه» یعنی چه.

### Request Body
```json
{
  "patient_uuid": "db7bde5a-…",
  "protocol_uuid": "6be5047e-…",
  "patient_package_uuid": "dcd21f8e-…"
}
```

`patient_package_uuid` اختیاری است (تسک ۱۱). پکیجی که این خدمت را پوشش ندهد `422` می‌گیرد.

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "8088fdd5-f19f-483e-b21b-11118e1b5e29",
    "patient_uuid": "db7bde5a-…",
    "service_uuid": "acea173f-…",
    "service_name": "لیزر فول‌بادی",
    "protocol_uuid": "6be5047e-…",
    "session_count": 8,
    "min_days": 21,
    "ideal_days": 28,
    "max_days": 45,
    "patient_package_uuid": null,
    "preferred_resource_uuid": null,
    "preferred_resource_name": null,
    "package_balance": 4,
    "package_shortfall": 2,
    "status": "active",
    "abandon_reason": null,
    "started_at": 1785484481,
    "completed_at": null,
    "progress": {
      "completed": 0,
      "booked": 0,
      "planned": 8,
      "skipped": 0,
      "total": 8,
      "next_session_number": 1,
      "next_params": { "energy": 12 },
      "last_completed_at": null
    },
    "sessions": [
      {
        "uuid": "08c4b98a-…",
        "session_number": 1,
        "params": { "energy": 12 },
        "appointment_uuid": null,
        "slot_start": null,
        "status": "planned",
        "completed_at": null
      },
      { "session_number": 3, "params": {}, "…": "جلسه‌ای که پروتکل برایش پارامتر ندارد" }
    ]
  }
}
```


> `preferred_resource_name` نام همان منبع است و فقط برای نمایش می‌آید — پنل با آن روی
> پیشنهاد جلسهٔ بعدی می‌نویسد کدام دستگاه ترجیح داده می‌شود. **ترجیح است نه الزام:**
> موتور آن را جلوتر می‌آورد ولی اگر آزاد نباشد منبع دیگری می‌دهد، و متن UI هم همین را
> می‌گوید تا انتظار اشتباه نسازد.

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | دورهٔ فعال دیگری برای همین سرویس هست — **پیام شناسهٔ آن دوره را می‌دهد** |
| `ERR_VALIDATION_002` | 422 | `patient_uuid` یا `protocol_uuid` غایب |
| `ERR_NOT_FOUND_001` | 404 | بیمار، پروتکل یا پکیج خارج از محیط جاری |

```json
{
  "success": false,
  "data": null,
  "errors": [{
    "code": "ERR_VALIDATION_001",
    "message": "این بیمار یک دورهٔ فعال برای همین خدمت دارد (8088fdd5-f19f-483e-b21b-11118e1b5e29)",
    "field": "course_uuid"
  }]
}
```

---

## GET `/api/v1/treatment-course/{uuid}` · `/api/v1/patient/{uuid}/courses`

همان بدنهٔ بالا. `patient/{uuid}/courses` جلسات را نمی‌دهد، فقط دوره + `progress`.

---

## GET `/api/v1/treatment-course/{uuid}/next-slot-suggestion`

| Query | Type | Required |
|---|---|---|
| `branch_uuid` | string | ✅ |

### Response `200`
```json
{
  "success": true,
  "data": {
    "session_number": 4,
    "params": { "energy": 18 },
    "ideal_at": 1787903681,
    "range": { "min": 1787298881, "max": 1789372481 },
    "suggested_slots": [{ "start": 1787903681, "end": 1787905481 }],
    "warning": null
  }
}
```

`warning` وقتی پر می‌شود که از حداکثر فاصله عبور شده باشد:

```
"از حداکثر فاصلهٔ مجاز (45 روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید."
```

فاصلهٔ مؤثر **سخت‌گیرانه‌ترین** بین پروتکل دوره و قانون `spacing` (تسک ۰۹) است: قانون
کلینیک نباید با پروتکل بجنگد، هر کدام سخت‌گیرتر بود همان اجرا می‌شود.

زمان گذشته پیشنهاد نمی‌شود؛ بیمارِ دیرکرده از همین حالا وقت می‌گیرد.

---

## POST `/api/v1/treatment-course/{uuid}/book-all`

رزرو همهٔ جلسات باقی‌مانده.

### Request Body
```json
{ "branch_uuid": "…", "doctor_uuid": "…" }
```

### Response `200`
```json
{
  "success": true,
  "data": {
    "booked": 3,
    "remaining": 5,
    "message": "5 جلسه بیرون از بازهٔ 90 روزهٔ رزرو افتاد و برنامه‌ریزی‌شده ماند؛ نزدیک‌تر که شدیم رزروشان کنید.",
    "course": { "…": "همان بدنهٔ دوره" }
  }
}
```

سه قاعده:

۱. **همه یا هیچ** — کل حلقه در یک تراکنش. اگر برای جلسهٔ ۵ وقتی نبود، جلسات ۱ تا ۴ هم
   rollback می‌شوند و `422` با شمارهٔ جلسهٔ مشکل‌دار برمی‌گردد. رزرو نیمه‌کاره بدترین
   حالت است: بیمار فکر می‌کند دوره‌اش رزرو شده و نصفش نیست.
۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله می‌گیرد.
۳. **افق ۹۰ روز** — جلساتی که بیرون بازهٔ جستجو می‌افتند `planned` می‌مانند و در `message`
   گزارش می‌شوند. این خطا نیست.

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | برای یکی از جلسات وقتی در بازهٔ مجاز نبود |
| `ERR_VALIDATION_002` | 422 | `branch_uuid` یا `doctor_uuid` غایب |

---

## POST `/api/v1/treatment-course/{uuid}/abandon`

```json
{ "reason": "انصراف بیمار" }
```

دلیل اجباری است. دورهٔ رهاشده جا را برای دورهٔ تازهٔ همان سرویس باز می‌کند.

⚠️ **رهاکردن دوره، نوبت‌های رزروشده را لغو نمی‌کند.** لغو نوبت عملی برگشت‌ناپذیر روی
ظرفیت شعبه است و باید تصمیم صریح اپراتور باشد، نه اثر جانبی بستن یک دوره. نوبت‌ها را
جداگانه لغو کنید.

---

## چرخهٔ جلسه و اتصال به نوبت

| اتفاق | چه می‌شود |
|---|---|
| رزرو جلسه | `CourseSession` → `booked` و پیوند دوطرفه با نوبت |
| لغو نوبت | همان جلسه → `planned`؛ **بقیهٔ دوره دست‌نخورده** |
| انجام جلسه | `completed` + `completed_at`؛ دوره وقتی `completed` می‌شود که همهٔ جلساتش تمام شده باشند |

پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`) تا
لیست نوبت‌ها بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را پیدا کند.
هر دو ستون فقط در `CourseSessionLinker` نوشته می‌شوند.

---

## طبقه‌بندی محیط

| جدول | وضعیت |
|---|---|
| `course_protocols` · `treatment_courses` · `course_sessions` | جفت محیط |
| `course_protocol_steps` | `AGGREGATE_CHILDREN` — ریشه `CourseProtocol` |

## تست‌ها

```bash
ddev exec php bin/phpunit tests/Course   # ۱۴ تست
```

مهم‌ترین‌ها: `testChangingTheProtocolLeavesRunningCoursesAlone` (snapshot)،
`testTheSuggestionAnchorsOnTheLastCompletedSession` (لنگر متحرک) و
`testCancellingOneSessionOnlyResetsThatSession`.

## اعتبار پکیج در برابر جلسات باقی‌مانده

`package_balance` مانده و `package_shortfall` کسری آن نسبت به جلسات **انجام‌نشده** است
(`null` وقتی دوره پکیجی ندارد).

کسری، دوره را باطل نمی‌کند و خطا هم نیست: بقیهٔ جلسات با قیمت عادی حساب می‌شوند. ولی
باید پیش از جلسهٔ ششم دانسته شود نه سرِ آن، پس صفحهٔ دوره آن را به‌صورت هشدار نشان می‌دهد.
