Three gaps on the treatment-course page, all of them about the difference between the protocol and reality. The sessions table listed each date but not the gap between them, leaving the operator to subtract two Jalali dates in their head. It now shows the real gap and colours it as a warning past the protocol maximum. A course cancelled mid-way stretches silently: the session goes back to planned and nobody is told. The suggestion endpoint does warn, but only once a branch is picked, so the warning could go unseen indefinitely. The page now derives "N days since the last session, past the protocol maximum" from the course itself, so it shows immediately. The course's preferred resource was applied by the engine but never named in the UI. The API now returns preferred_resource_name alongside the uuid, and the text says plainly that it is a preference — the engine moves it up the list, it does not hold the slot. Two backend tests that were owed: the stricter of the protocol spacing and a spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a clinic's safety rule could be bypassed by writing a short protocol), and a session whose earliest possible date falls outside the 90-day horizon is skipped rather than failing book-all, leaving the course untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
Markdown
309 lines
12 KiB
Markdown
# 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,
|
||
"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`.
|