feat(course): treatment courses with protocol-driven session planning

Laser is six to eight sessions; the previous design only knew single
appointments, which is the exception rather than the rule.

- CourseProtocol per service: session count and three distinct spacings —
  min is the earliest that is clinically allowed, ideal is best, max is where
  the course starts losing its effect
- Starting a course creates every session up front as `planned` and copies the
  protocol's numbers and per-session params, so changing the protocol tomorrow
  leaves a running course alone
- Suggestions anchor on the last *completed* session, not the course start:
  when session 2 slips, session 3 moves with it
- Slots are ranked by distance from ideal, not by earliest available — day 21
  is worse than day 27 when 28 is the target
- book-all is all-or-nothing inside one transaction, with a moving anchor and a
  90-day horizon; sessions past the horizon stay planned and are reported, not
  treated as failures
- The effective minimum is the stricter of the protocol and the task-09 spacing
  policy, so a clinic rule never fights the protocol
- Cancelling one session returns only that session to planned; abandoning a
  course does not cancel its appointments, which stays an explicit decision

One active course per (patient, service) via active_course_key, the same
partial-uniqueness trick as Appointment::activeSlotKey.

Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the
patient record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 11:33:07 +03:30
co-authored by Claude Opus 5
parent edf22e0552
commit fc504f4415
30 changed files with 3502 additions and 75 deletions
+301
View File
@@ -0,0 +1,301 @@
# 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,
"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": {}, "…": "جلسه‌ای که پروتکل برایش پارامتر ندارد" }
]
}
}
```
### 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`.