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:
@@ -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`.
|
||||
Reference in New Issue
Block a user