Files
clinicpro/docs/api/course.md
T
hamedandClaude Opus 5 fc504f4415 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>
2026-07-31 11:33:07 +03:30

302 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.