feat(treatment): add treatment protocols, the multi-session course of a service

A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.

Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.

Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.

Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.

ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:36:04 +03:30
co-authored by Claude Opus 5
parent 85985b04a0
commit e2e3e6b43b
21 changed files with 1107 additions and 1200 deletions
+1
View File
@@ -82,6 +82,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
| [doctor.md](doctor.md) | Doctor profile & addresses | 11 |
| [clinic.md](clinic.md) | Clinics | 7 |
| [practice-domain.md](practice-domain.md) | Practice domains — a clinic's field of practice | 3 |
| [treatment.md](treatment.md) | Treatment protocols — multi-session courses on a service | 3 |
| [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 |
| [resource.md](resource.md) | Resources, types, skills, pools | 16 |
| [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 |
-318
View File
@@ -1,318 +0,0 @@
# 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` وقتی دوره پکیجی ندارد).
کسری، دوره را باطل نمی‌کند و خطا هم نیست: بقیهٔ جلسات با قیمت عادی حساب می‌شوند. ولی
باید پیش از جلسهٔ ششم دانسته شود نه سرِ آن، پس صفحهٔ دوره آن را به‌صورت هشدار نشان می‌دهد.
+143
View File
@@ -0,0 +1,143 @@
# Treatment API
> **Prefix:** `/api/v1/service-item/{uuid}/treatment-protocol`
A **treatment protocol** is the "طول درمان" of a service: it says a course of that service runs over
several sessions, when each one falls due, which doctor supervises it, and which staff may perform
it. A service without a protocol row is single-session — the row's existence *is* the switch, which
is why there is no separate boolean.
`ServiceItem.session_count` is **deprecated**. It never had logic behind it; session count now comes
from the protocol's step list. The column still appears in service payloads so existing clients do
not break, but nothing should read it.
Superseded design note: an earlier `src/Course/` module (`CourseProtocol` / `TreatmentCourse`) was
deleted in `65d5831c` and its docs removed. It modelled the gap between sessions as one
min/ideal/max triple for the whole course, which cannot express a course whose intervals differ per
session — a botox course is session 1, then +15 days, then monthly. This design stores an explicit
offset per step instead.
---
## The step model
Each step carries `offset_days`, and that offset is measured **from the previous session**, not from
the start of the course. The spacing of a laser course is a clinical requirement — hair regrows
relative to the last treatment, not relative to when the file was opened — so a patient who arrives
late shifts the rest of their course rather than getting the next session too early.
| Rule | چرا |
|---|---|
| حداقل ۲ گام | یک گام یعنی سرویس تک‌جلسه‌ای؛ سوییچ اصلاً نباید روشن باشد |
| حداکثر ۶۰ گام | سقف عقلانی، جلوی ورودی اشتباه را می‌گیرد |
| `step_number` پیوسته از ۱ | «جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که گامی جا نیفتاده باشد |
| گام ۱`offset_days = 0` | لنگر دوره است و «جلسهٔ قبل» ندارد |
| گام‌های بعدی → `offset_days > 0` | فاصلهٔ صفر یعنی دو جلسه در یک روز |
| حداقل یک پرسنل مجاز | بدون آن هر پرسنلی پای هر دستگاهی می‌نشیند |
---
## GET `/api/v1/service-item/{uuid}/treatment-protocol`
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری — سرویس محیط دیگر `404` می‌گیرد.
`data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "8de51c47-ddd1-44ea-bae1-83cf6457b182",
"service_uuid": "cc0b11ec-1c39-45f0-bc1d-5514619bc74a",
"active": true,
"total_sessions": 4,
"supervisor": null,
"steps": [
{ "step_number": 1, "offset_days": 0 },
{ "step_number": 2, "offset_days": 15 },
{ "step_number": 3, "offset_days": 30 },
{ "step_number": 4, "offset_days": 30 }
],
"staff": [
{ "uuid": "53acc523-48a7-4f4a-89b1-745e8e7a69bd", "name": "پرسنل۱" }
]
}
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_NOT_FOUND_001 | 404 | سرویس یافت نشد یا مال محیط دیگری است |
| ERR_AUTH_001 | 401 | بدون توکن |
---
## PUT `/api/v1/service-item/{uuid}/treatment-protocol`
Replace the whole protocol. Creates it on first call, so this doubles as "turn the switch on".
Everything is validated **before** anything is written: an invalid step at the end of the list must
not wipe the valid steps already stored. Steps and staff are then cleared and rewritten inside one
transaction.
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
### Request Body (`application/json`)
| Field | Type | Required | توضیح |
|---|---|---|---|
| `steps` | array | ✅ | ۲ تا ۶۰ گام |
| `steps[].step_number` | int | ❌ | پیوسته از ۱؛ نبودنش یعنی ترتیب آرایه |
| `steps[].offset_days` | int | ✅ | فاصله از جلسهٔ **قبلی** |
| `staff_uuids` | string[] | ✅ | حداقل یکی، همه از محیط جاری و فعال؛ تکراری‌ها حذف می‌شوند |
| `supervisor_doctor_uuid` | string | ❌ | پزشک ناظر؛ باید عضو همین محیط باشد |
```json
{
"staff_uuids": ["53acc523-48a7-4f4a-89b1-745e8e7a69bd"],
"supervisor_doctor_uuid": null,
"steps": [
{ "step_number": 1, "offset_days": 0 },
{ "step_number": 2, "offset_days": 15 },
{ "step_number": 3, "offset_days": 30 },
{ "step_number": 4, "offset_days": 30 }
]
}
```
### Response `200`
Same shape as GET.
**Errors:**
| Code | HTTP | Field | توضیح |
|------|------|-------|-------|
| ERR_VALIDATION_001 | 422 | `steps` | کمتر از ۲ یا بیشتر از ۶۰ گام |
| ERR_VALIDATION_002 | 422 | `steps` | فیلد `steps` نیست یا آرایه نیست |
| ERR_VALIDATION_002 | 422 | `offset_days` | `offset_days` یک گام نیست یا عدد نیست |
| ERR_VALIDATION_001 | 422 | `offset_days` | گام اول صفر نیست، یا گام بعدی صفر/منفی است |
| ERR_VALIDATION_001 | 422 | `step_number` | شماره‌ها پیوسته از ۱ نیستند |
| ERR_VALIDATION_002 | 422 | `staff_uuids` | فهرست خالی است یا uuid نامعتبر دارد |
| ERR_NOT_FOUND_001 | 404 | `staff_uuids` | پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است |
| ERR_NOT_FOUND_001 | 404 | — | پزشک ناظر عضو این محیط نیست |
Real 422 responses:
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"دورهٔ درمان حداقل 2 جلسه دارد؛ کمتر از آن یعنی سرویس تک‌جلسه‌ای","field":"steps"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"حداقل یک پرسنل مجاز الزامی است","field":"staff_uuids"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"شمارهٔ گام‌ها باید پیوسته از ۱ باشد؛ گام 2 انتظار می‌رفت","field":"step_number"}]}
```
---
## DELETE `/api/v1/service-item/{uuid}/treatment-protocol`
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
single-session again. Idempotent: deleting a service that has no protocol still answers `200`.
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
### Response `200`
```json
{ "success": true, "data": null }
```