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 }
```
@@ -1,213 +0,0 @@
# معماری — تسک ۱۲
## ساختار فایل
```
src/Course/
├── Entity/
│ ├── CourseProtocol.php
│ ├── CourseProtocolStep.php # پارامتر هر جلسه
│ ├── TreatmentCourse.php
│ └── CourseSession.php
├── Service/
│ ├── CourseStarter.php # شروع دوره از پروتکل
│ ├── CourseScheduler.php # رزرو یکجا + پیشنهاد جلسهٔ بعدی
│ ├── CourseProgressCalculator.php
│ └── CourseSessionLinker.php # اتصال نوبت ↔ جلسهٔ دوره
├── Controller/{CourseProtocolController, TreatmentCourseController}.php
└── Repository/…
```
## `CourseProtocol` و `CourseProtocolStep`
```php
class CourseProtocol
{
use TenantOwnedTrait;
private ServiceItem $service;
private int $sessionCount; // ۸
private int $minDays; // ۲۱
private int $idealDays; // ۲۸
private int $maxDays; // ۴۵
private bool $preferSameResource = true;
private Collection $steps; // CourseProtocolStep
}
class CourseProtocolStep
{
private int $sessionNumber; // ۱..۸
private array $params = []; // {"energy": 12} — اسکالر، فهرست آزاد
private ?int $overrideDurationMinutes = null; // جلسهٔ اول طولانی‌تر است
}
```
`params` آزاد است چون هر تخصص پارامتر خودش را دارد (سطح انرژی، ضخامت، دوز). ولی مثل
`ClinicResource.attributes` فقط اسکالر — و هیچ منطقی به مقدارش وابسته نیست، فقط نمایش و
ثبت می‌شود.
`minDays <= idealDays <= maxDays` قید اجباری.
## `TreatmentCourse` و `CourseSession`
```php
class TreatmentCourse
{
use TenantOwnedTrait;
public const STATUS_ACTIVE = 'active';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ABANDONED = 'abandoned';
private PatientRecord $patient;
private ServiceItem $service;
private CourseProtocol $protocol;
// ── snapshot پروتکل در لحظهٔ شروع (قانون پنجم مستند) ──
private int $sessionCount;
private int $minDays;
private int $idealDays;
private int $maxDays;
private ?PatientPackage $package = null; // تسک ۱۱ — اختیاری
private ?ClinicResource $preferredResource = null; // منبع جلسهٔ اول
private string $status = self::STATUS_ACTIVE;
private int $startedAt;
}
class CourseSession
{
public const STATUS_PLANNED = 'planned';
public const STATUS_BOOKED = 'booked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_SKIPPED = 'skipped';
private TreatmentCourse $course;
private int $sessionNumber;
private array $params = []; // snapshot از CourseProtocolStep
private ?Appointment $appointment = null;
private string $status = self::STATUS_PLANNED;
private ?int $completedAt = null;
}
```
چهار فیلد فاصله و `params` **کپی** می‌شوند نه FK: تغییر پروتکل فردا نباید دورهٔ در جریان
را عوض کند. این همان تصمیمی است که در `appointment_segments` و `patient_packages` گرفته شد.
## `CourseScheduler` — رزرو یکجا
```php
public function bookAll(TreatmentCourse $course): BookAllResult
{
return $this->em->wrapInTransaction(function () use ($course) {
$anchor = $this->lastCompletedAt($course) ?? time();
$planned = $course->plannedSessions(); // مرتب بر اساس sessionNumber
$holds = [];
foreach ($planned as $session) {
$target = $anchor + $course->getIdealDays() * 86400;
if ($target > time() + 90 * 86400) {
// بیرون از بازهٔ مجاز جستجو — این و بقیه planned می‌مانند
break;
}
$slot = $this->findNearestInRange(
$course, $session,
min: $anchor + $course->getMinDays() * 86400,
ideal: $target,
max: $anchor + $course->getMaxDays() * 86400,
);
if ($slot === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf(
'برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber()
), 422);
}
$holds[] = $this->holdService->hold($this->holdRequestFor($course, $session, $slot));
$anchor = $slot->start; // ← لنگر جلسهٔ بعدی، همین جلسه
}
foreach ($holds as $hold) { $this->bookingService->confirm($hold->uuid, $course->owner()); }
return new BookAllResult(count($holds), count($planned) - count($holds));
});
}
```
سه نکتهٔ حیاتی:
1. **همه یا هیچ** — کل حلقه در یک تراکنش. استثنا در جلسهٔ ۵ یعنی rollback جلسات ۱ تا ۴.
رزرو نیمه‌کاره بدترین حالت است: بیمار فکر می‌کند دوره‌اش رزرو شده.
2. **لنگر متحرک** — فاصله از جلسهٔ **قبلی** حساب می‌شود، نه از شروع دوره. اگر جلسهٔ ۲
سه روز دیرتر افتاد، جلسهٔ ۳ هم جابه‌جا می‌شود.
3. **سقف ۹۰ روز** — محدودیت جستجوی تسک ۰۶. جلسات بیرون بازه `planned` می‌مانند و بیمار
بعداً رزرو می‌کند. پیام روشن اجباری است.
## `findNearestInRange` — نزدیک‌ترین به ایده‌آل
```php
$slots = $this->availability->search($req->withRange($min, $max));
if ($slots === []) return null;
usort($slots, fn($a, $b) => abs($a->start - $ideal) <=> abs($b->start - $ideal));
return $slots[0];
```
نزدیک‌ترین به ایده‌آل، نه اولین موجود. ۲۸ روز ایده‌آل است؛ روز ۲۱ (حداقل) از نظر
درمانی بدتر از روز ۲۷ است.
## `same_as_previous` — اتصال به تسک ۰۶
```php
// SameAsPreviousPicker (تسک ۰۶) به یک ورودی نیاز دارد که تا حالا نداشت
public function pick(array $freeIds, PlannedRequirement $req, OccupancyIndex $idx, AppointmentPlan $plan): int
{
$preferred = $plan->context()->preferredResourceIds ?? [];
foreach ($preferred as $id) {
if (in_array($id, $freeIds, true)) return $id;
}
return $this->fallback->pick($freeIds, $req, $idx, $plan); // least_gap
}
```
`preferredResourceIds` از `TreatmentCourse.preferredResource` می‌آید و در `PlanRequest`
حمل می‌شود. اگر منبع ترجیحی آزاد نبود، **رزرو رد نمی‌شود** — به `least_gap` برمی‌گردد.
اجبار به همان منبع یعنی بیمار دو هفته منتظر بماند.
## پیشنهاد جلسهٔ بعدی
```
GET /treatment-course/{uuid}/next-slot-suggestion
{
"session_number": 4,
"params": { "energy": 18 },
"ideal_date": "1405-06-01",
"range": { "min": "1405-05-25", "max": "1405-06-18" },
"suggested_slots": [ … سه وقت نزدیک به ایده‌آل … ],
"warning": null
}
```
`warning` وقتی پر می‌شود که `now > lastCompleted + maxDays`:
«از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.»
## اتصال به `spacing` تسک ۰۹
قانون `spacing` و پروتکل دوره هر دو فاصله را محدود می‌کنند. **قانون برنده است** اگر
سخت‌گیرانه‌تر باشد:
```php
$effectiveMin = max($course->getMinDays(), $policyMinDays ?? 0);
```
دلیل: پروتکل پیشنهاد بالینی است، قانون سیاست کلینیک. سیاست کلینیک نمی‌تواند شل‌تر شود.
این را در `docs/api/course.md` بنویس.
## پنل ادمین
- `CourseProtocolsPage.tsx` — پروتکل per سرویس + جدول پارامتر جلسات
- `TreatmentCoursePage.tsx` — نوار پیشرفت («۳ از ۸»)، جدول جلسات با وضعیت و تاریخ،
دکمهٔ «رزرو جلسهٔ بعدی» و «رزرو همهٔ جلسات»
- کارت دوره‌ها در `PatientDetailPage.tsx`
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
همان می‌فهمد بیمار منظم است یا نه
@@ -1,130 +0,0 @@
# چک‌لیست — تسک ۱۲ (دوره درمان)
> # ⛔ این تسک از محصول حذف شد
>
> **تصمیم مالک محصول، ۱۴۰۵/۰۵/۱۰:** مدل نوبت‌دهی به منبع/سرویس/گزینه محدود شد و هر چیز
> خارج از آن حذف شد. کد، جدول‌ها، endpointها، تست‌ها و صفحات پنل این تسک در کامیت
> «Remove the policy, package, course, cancellation and event subsystems» برداشته شدند.
>
> ریسکش پیش از اجرا دو بار مطرح و دو بار تأیید شد. ردیف‌های زیر **تاریخچه**اند، نه کار
> جاری؛ برای برگرداندن به همان کامیت رجوع کنید.
>
> مدل جایگزین: [`docs/architecture/resource-first-model.md`](../../../architecture/resource-first-model.md)
> و چک‌لیست [تسک ۱۵](../task-15-resource-first-model/checklist.md).
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
---
## ۰. خط سرخ
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
| ۰.۲ | `PatientSession` موجود دست‌نخورده | ✅ | «مراجعهٔ انجام‌شده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد |
| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر می‌شوند | ✅ | صندوق خروجی تسک ۱۴ همین را تضمین می‌کند: `record()` فلاش نمی‌کند، پس rollbackِ `book-all` رویدادی جا نمی‌گذارد |
| ۰.۴ | `abandon` نوبت‌های `booked` را لغو نمی‌کند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | چهار entity | ✅ | |
| ۱.۲ | سرویس‌ها | ✅ | `CourseStarter` · `CourseScheduler` · `CourseBooker` · `CourseProgressCalculator` · `CourseSessionLinker` |
| ۱.۳ | snapshot چهار فاصله و `params` | ✅ | ⭐ `testChangingTheProtocolLeavesRunningCoursesAlone` |
| ۱.۴ | `book-all` همه یا هیچ | ✅ | ⭐⭐ `wrapInTransaction` دور کل حلقه |
| ۱.۵ | لنگر متحرک | ✅ | ⭐ لنگر بعد از هر رزرو روی همان اسلات می‌رود |
| ۱.۶ | نزدیک‌ترین به ایده‌آل | ✅ | `usort` روی `abs(start - ideal)` |
| ۱.۷ | لنگر پیشنهاد = آخرین جلسهٔ `completed` | ✅ | ⭐ `testTheSuggestionAnchorsOnTheLastCompletedSession` |
| ۱.۸ | سقف ۹۰ روز + پیام روشن | ✅ | ⭐ جلسات بیرون بازه `planned` می‌مانند، خطا نیست |
| ۱.۹ | `same_as_previous` ترجیح نه الزام | ✅ | ⭐ منبع ترجیحی جلو می‌آید، بقیه حذف نمی‌شوند؛ اجبار یعنی بیمار دو هفته منتظر بماند |
| ۱.۱۰ | `preferredResourceIds` حمل می‌شود | ✅ | `POST /appointment-availability` فیلد `course_uuid` می‌گیرد و `preferred_resource` دوره را به موتور می‌دهد |
| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ | ✅ | همراه سه استراتژی دیگر در تسک ۰۶ ساخته شد |
| ۱.۱۲ | تعامل با `spacing`: سخت‌گیرانه‌تر برنده | ✅ | تصمیم ثبت‌شده در [deviations.md](../../../architecture/deviations.md) — `max(min)` پیاده شد (`effectiveMinDays``min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا می‌رود |
| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ✅ | `package_balance` و `package_shortfall` در پاسخ، هشدار در صفحهٔ دوره. یادداشت قبلی: مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمی‌دهد |
| ۱.۱۴ | `active_course_key` | ✅ | ⭐ همان الگوی `active_slot_key` |
| ۱.۱۵ | `CourseSessionLinker` تنها نویسندهٔ رابطهٔ دوطرفه | ✅ | |
| ۱.۱۶ | جلسهٔ آخر → دوره `completed` خودکار | ✅ | رویدادش با تسک ۱۴ می‌آید |
| ۱.۱۷ | نُه endpoint | ✅ | ۹ تا: پروتکل GET/POST/GET{uuid}/PATCH/DELETE + دوره POST/GET/`patient/{uuid}/courses`/`next-slot-suggestion`/`book-all`/`abandon` |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeTheCourse` |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | چهار جدول | ✅ | `Version20260731074710` |
| ۲.۲ | قیدهای فاصله و تعداد | ✅ | در سازنده، با ۴۲۲ روشن |
| ۲.۳ | یکتایی شمارهٔ جلسه | ✅ | هم روی پروتکل هم روی دوره |
| ۲.۴ | `UNIQUE(appointment_id)` | ✅ | یک نوبت به بیش از یک جلسه وصل نمی‌شود |
| ۲.۵ | `appointments.course_session_id` | ✅ | `Version20260731074758` |
| ۲.۶ | `course_protocol_steps` در `AGGREGATE_CHILDREN` | ✅ | |
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `CourseProtocolsPage` | ✅ | با اعتبارسنجی ترتیب فاصله‌ها **در خود فرم** |
| ۳.۲ | `TreatmentCoursePage` | ✅ | دکمهٔ «رزرو همهٔ جلسات» با انتخابگر پزشک اضافه شد. یادداشت قبلی: (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) |
| ۳.۳ | دوره‌های بیمار در `PatientDetailPage` | ✅ | تب «دوره‌های درمان» |
| ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ✅ | ⭐ فاصلهٔ **واقعی** با جلسهٔ قبلی؛ عبور از حداکثر پروتکل با رنگ هشدار |
| ۳.۵ | هشدار عبور از حداکثر فاصله | ✅ | با رنگ `--warning` |
| ۳.۶ | بنر پیشنهاد جلسهٔ بعدی | ✅ | در کارت بالای صفحهٔ دوره |
| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو | ✅ | `preferred_resource_name` در پاسخ دوره؛ متن صریح می‌گوید ترجیح است نه الزام |
| ۳.۸ | پیشنهاد بازچینی پس از لغو وسط دوره | ✅ | ⭐ بنر «N روز از آخرین جلسه گذشته» از خودِ دوره حساب می‌شود، پس به انتخاب شعبه وابسته نیست |
| ۳.۹ | `DataTable` برای جلسات | ✅ | |
| ۳.۱۰ | نشان وضعیت جلسه و دوره | ✅ | کلاس‌های `badge` موجود |
| ۳.۱۱ | تاریخ‌ها شمسی | ✅ | `formatDate` |
| ۳.۱۲ | `backTo` روی زیرصفحه‌ها | ✅ | |
| ۳.۱۳ | هیچ رنگ/شعاع hard-code | ✅ | |
| ۳.۱۴ | دارک‌مود و حالت فشرده | ✅ | اسکرین‌شات واقعی |
| ۳.۱۵ | RTL و موبایل | ✅ | جدول جلسات اسکرول افقی داخلی دارد |
| ۳.۱۶ | همهٔ رشته‌ها فارسی | ✅ | |
| ۳.۱۷ | پیام سقف ۹۰ روز در UI | ✅ | از پاسخ `book-all` به‌صورت toast |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | |
| ۴.۲ | snapshot پروتکل | ✅ | ⭐ |
| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ✅ | تصمیم ثبت‌شده در [deviations.md](../../../architecture/deviations.md) — لنگر پیشنهاد، افق، و مسیر شکستِ `book-all` تست دارند؛ مسیر موفقِ چندجلسه‌ای هنوز نه |
| ۴.۴ | شکست جلسهٔ N → rollback | ✅ | ⭐ تقویم فقط یک‌روزه: جلسهٔ اول وقت پیدا می‌کند، دومی نه، و **هیچ** جلسه‌ای رزرو نمی‌ماند |
| ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد می‌شود، دوره دست‌نخورده می‌ماند |
| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ |
| ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` |
| ۴.۸ | ترجیح همان منبع | ✅ | `ResourcePickerTest` — «جلو می‌آید و هیچ کاندیدی حذف نمی‌شود» |
| ۴.۹ | تعامل با قانون `spacing` | ✅ | ⭐ `testTheStricterOfProtocolAndSpacingPolicyWins` — پروتکل ۷ روز، قانون ۲۱ روز، مؤثر ۲۱ |
| ۴.۱۰ | مصرف پکیج per جلسه | ✅ | مسیر مصرف از تسک ۱۱ می‌آید و `credit_refundable` روی دوره هم تست شد. یادداشت قبلی: تست اختصاصی نوشته نشد |
| ۴.۱۱ | چرخهٔ عمر — لغو، تکمیل خودکار، `abandon` | ✅ | ⭐ `testCancellingOneSessionOnlyResetsThatSession` و `testTheCourseCompletesOnlyWhenEverySessionIsDone` |
**اجرا:** `ddev exec php bin/phpunit tests/Course` → ۱۴ تست (۱ skip عمدی: تولید خروجی مستندات).
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/course.md` | ✅ | JSON واقعی از اجرای واقعی |
| ۵.۲ | «سخت‌گیرانه‌تر برنده» | ✅ | |
| ۵.۳ | رفتار سقف ۹۰ روز | ✅ | |
| ۵.۴ | «`abandon` نوبت‌ها را لغو نمی‌کند» | ✅ | با دلیلش |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ✅ | ۸ مورد ⏳/⚠️ همه با دلیل و تسک مقصد |
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۲۸۲ تست |
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline |
| ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۰ تست |
| ۶.۶ | تست‌های tenant سبز | ✅ | |
| ۶.۷ | `docs/api/*` به‌روز | ✅ | |
| ۶.۸ | چک‌لیست UI کامل | ✅ | همه؛ بازبینی چشمی دارک‌مود/فشرده انجام شد |
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ✅ | با graphify بررسی شدند؛ هیچ‌کدام دوره را مصرف نمی‌کنند. یادداشت قبلی: نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد |
| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |
@@ -1,138 +0,0 @@
# دیتابیس — تسک ۱۲
## `course_protocols`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `session_count` | SMALLINT NOT NULL | |
| `min_days` | SMALLINT NOT NULL | |
| `ideal_days` | SMALLINT NOT NULL | |
| `max_days` | SMALLINT NOT NULL | |
| `prefer_same_resource` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_protocol_service (service_item_id) -- یک پروتکل فعال per سرویس
KEY idx_protocols_tenant (entity_type, entity_id, active)
```
قید اپلیکیشنی: `min_days <= ideal_days <= max_days` و `session_count >= 2`
(دورهٔ یک‌جلسه‌ای همان نوبت تکی است).
## `course_protocol_steps`
```sql
CREATE TABLE course_protocol_steps (
id INT PRIMARY KEY AUTO_INCREMENT,
protocol_id INT NOT NULL,
session_number SMALLINT NOT NULL,
params JSON NULL, -- {"energy": 12} — اسکالر
override_duration_minutes SMALLINT NULL,
UNIQUE KEY uniq_step (protocol_id, session_number),
CONSTRAINT fk_step_protocol FOREIGN KEY (protocol_id) REFERENCES course_protocols(id) ON DELETE CASCADE
);
```
فرزند aggregate با ریشهٔ `CourseProtocol`.
## `treatment_courses`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `patient_record_id` | INT NOT NULL | FK ON DELETE RESTRICT |
| `service_item_id` | INT NOT NULL | FK ON DELETE RESTRICT |
| `protocol_id` | INT NOT NULL | FK ON DELETE RESTRICT |
| `session_count` | SMALLINT NOT NULL | **snapshot** |
| `min_days` | SMALLINT NOT NULL | **snapshot** |
| `ideal_days` | SMALLINT NOT NULL | **snapshot** |
| `max_days` | SMALLINT NOT NULL | **snapshot** |
| `patient_package_id` | INT NULL | FK → `patient_packages.id` ON DELETE SET NULL |
| `preferred_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL |
| `status` | VARCHAR(12) NOT NULL DEFAULT 'active' | `active`\|`completed`\|`abandoned` |
| `abandon_reason` | VARCHAR(255) NULL | |
| `started_at` | INT NOT NULL | |
| `completed_at` | INT NULL | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_courses_tenant (entity_type, entity_id, status, started_at)
KEY idx_courses_patient (patient_record_id, status)
UNIQUE KEY uniq_active_course (patient_record_id, service_item_id, status)
```
⚠️ `uniq_active_course` با MariaDB روی مقدار `status` کار نمی‌کند به شکلی که فقط
`active` را یکتا کند (چند ردیف `completed` مجازند). راه درست: **قید اپلیکیشنی** در
`CourseStarter` + کلید یکتای جزئی که MariaDB ندارد.
جایگزین: یک ستون `active_course_key VARCHAR(64) NULL UNIQUE` با همان الگوی
`Appointment.active_slot_key`:
```php
$this->activeCourseKey = $this->status === self::STATUS_ACTIVE
? sprintf('%d:%d', $this->patient->getId(), $this->service->getId())
: null;
```
الگوی اثبات‌شدهٔ همین کدبیس — استفاده‌اش کن، دوباره اختراع نکن.
## `course_sessions`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `course_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `session_number` | SMALLINT NOT NULL | |
| `params` | JSON NULL | **snapshot** از `course_protocol_steps` |
| `appointment_id` | INT NULL UNIQUE | FK ON DELETE SET NULL |
| `status` | VARCHAR(12) NOT NULL DEFAULT 'planned' | `planned`\|`booked`\|`completed`\|`skipped` |
| `completed_at` | INT NULL | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_course_session (course_id, session_number)
UNIQUE KEY uniq_session_appointment (appointment_id)
KEY idx_sessions_tenant (entity_type, entity_id, status)
KEY idx_sessions_course (course_id, session_number)
```
`uniq_session_appointment`: یک نوبت به بیش از یک جلسهٔ دوره وصل نمی‌شود.
## تغییر `appointments`
```sql
ALTER TABLE appointments
ADD COLUMN course_session_id INT NULL,
ADD CONSTRAINT fk_appointments_course_session
FOREIGN KEY (course_session_id) REFERENCES course_sessions(id) ON DELETE SET NULL,
ADD KEY idx_appointments_course_session (course_session_id);
```
دو طرفه است (`course_sessions.appointment_id` هم وجود دارد) — عمدی: لیست نوبت‌های پنل
باید بدون JOIN بفهمد نوبت جزو دوره است، و صفحهٔ دوره باید بدون JOIN نوبت را پیدا کند.
هر دو در `CourseSessionLinker` **هم‌زمان** ست می‌شوند؛ هیچ جای دیگری ننویسد.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
بدون backfill.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `course_protocols`, `treatment_courses`, `course_sessions` | جفت tenant |
| `course_protocol_steps` | `AGGREGATE_CHILDREN` → ریشه `CourseProtocol` |
@@ -1,173 +0,0 @@
# نکات پیاده‌سازی — تسک ۱۲
## ۱. `book-all` همه یا هیچ
```php
$this->em->wrapInTransaction(function () { /* همهٔ hold ها و confirm ها */ });
```
اگر جلسهٔ ۵ وقت نداشت، جلسات ۱ تا ۴ هم rollback می‌شوند. رزرو نیمه‌کاره یعنی بیمار
پیامک چهار نوبت می‌گیرد، فکر می‌کند دوره‌اش کامل رزرو شده، و چهار ماه بعد می‌فهمد نه.
⚠️ ولی رویدادها (پیامک) با `DispatchAfterCurrentBusStamp` بعد از commit می‌روند (تسک ۰۷)،
پس در حالت rollback هیچ پیامکی نرفته. این وابستگی را جدی بگیر: اگر کسی در تسک ۰۷
`dispatch` را قبل از commit گذاشته باشد، اینجا هشت پیامک اشتباه می‌رود.
## ۲. لنگر متحرک، نه تاریخ ثابت
```php
// ❌ فاصله از شروع دوره
$target = $course->getStartedAt() + $n * $idealDays * 86400;
// ✅ فاصله از جلسهٔ قبلی
$anchor = $slot->start; // در هر تکرار حلقه به‌روز می‌شود
```
اگر جلسهٔ ۲ چهار روز دیرتر افتاد، جلسهٔ ۳ هم باید چهار روز جابه‌جا شود — وگرنه فاصلهٔ
۲ به ۳ می‌شود ۲۴ روز و از حداقل ۲۱ رد نمی‌شود ولی از نظر درمانی غلط است.
## ۳. لنگر پیشنهاد بعدی: آخرین جلسهٔ **انجام‌شده**
```php
private function lastCompletedAt(TreatmentCourse $course): ?int
{
// status = completed، نه booked
return $this->sessionRepo->maxCompletedAt($course);
}
```
اگر از آخرین جلسهٔ `booked` حساب کنی، بیمار که نوبتش را لغو کرد یا نیامد، پیشنهاد بعدی
غلط می‌شود. فقط جلسهٔ واقعاً انجام‌شده لنگر است.
جلسهٔ اول دوره: لنگر `time()` است، یا `started_at`.
## ۴. snapshot پروتکل
چهار فیلد فاصله و `params` هر جلسه کپی می‌شوند. تست:
```php
// tests/Course/ProtocolSnapshotTest.php
$course = $this->starter->start($patient, $service); // protocol: 8 جلسه، 28 روز
$protocol->setIdealDays(14)->setSessionCount(4);
$this->em->flush();
self::assertSame(28, $course->getIdealDays());
self::assertCount(8, $course->getSessions());
```
قانون پنجم مستند. بدون این، کلینیک که پروتکل را عوض کند، دوره‌های در جریان ۵۰ بیمار
یک‌شبه بی‌معنا می‌شوند.
## ۵. سقف ۹۰ روز و پیام روشن
۸ جلسه × ۲۸ روز = ۲۲۴ روز. جستجوی تسک ۰۶ فقط ۹۰ روز است. پس `book-all` معمولاً
۳ تا ۴ جلسه رزرو می‌کند و بقیه `planned` می‌مانند.
پاسخ باید صریح بگوید:
```json
{
"booked_count": 3,
"remaining_planned": 5,
"message": "۳ جلسهٔ نخست رزرو شد. بقیهٔ جلسات خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند و بعداً قابل رزروند."
}
```
بدون این پیام، کاربر فکر می‌کند سیستم خراب است.
## ۶. `same_as_previous` اجباری نیست
```php
foreach ($preferred as $id) {
if (in_array($id, $freeIds, true)) return $id;
}
return $this->fallback->pick(); // ← نه throw
```
اگر اپراتور جلسهٔ اول مرخصی است، بیمار نباید دو هفته منتظر بماند. ترجیح، نه الزام.
اگر کلینیکی الزام واقعی داشت، آن یک قانون `resource` با `specific_resource` است (تسک ۰۹).
## ۷. تعامل با `spacing` تسک ۰۹
```php
$effectiveMin = max($course->getMinDays(), $this->policies->minDaysFor($ctx) ?? 0);
$effectiveMax = min($course->getMaxDays(), $this->policies->maxDaysFor($ctx) ?? PHP_INT_MAX);
if ($effectiveMin > $effectiveMax) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001,
'قوانین کلینیک با پروتکل این دوره سازگار نیستند', 422);
}
```
سخت‌گیرانه‌تر برنده. و اگر ترکیبشان بازهٔ تهی ساخت، خطای روشن — نه جستجوی بی‌نتیجه.
## ۸. اتصال به پکیج
اگر `TreatmentCourse.package` پر باشد، هر `confirm` جلسه یک واحد اعتبار مصرف می‌کند
(تسک ۱۱). `book-all` هشت جلسه یعنی هشت مصرف — پس پیش از شروع:
```php
if ($course->getPackage() !== null) {
$balance = $this->ledger->balance($course->getPackage());
if ($balance < count($plannedSessions)) {
// خطا نیست — هشدار
$result->addWarning(sprintf('اعتبار پکیج (%d) کمتر از جلسات باقی‌مانده (%d) است', $balance, $count));
}
}
```
هشدار نه خطا: بیمار می‌تواند بقیه را نقدی بپردازد.
## ۹. edge case ها
| حالت | رفتار درست |
|---|---|
| دورهٔ فعال دوم برای همان سرویس | `422` با uuid دورهٔ موجود در `meta` |
| لغو جلسهٔ وسط دوره | `CourseSession``planned`، `appointment_id` → NULL، بقیه دست‌نخورده |
| عدم حضور (`no_show`) در جلسه | `CourseSession``skipped`؛ لنگر همان جلسهٔ قبلی می‌ماند |
| جلسهٔ آخر `completed` | دوره → `completed` خودکار + رویداد `CourseCompleted` |
| `abandon` دورهٔ نیمه‌کاره | جلسات `booked` **لغو نمی‌شوند** خودکار — پاسخ شامل تعدادشان و لینک |
| بیمار ۶۰ روز غیبت (> max) | `warning` در پیشنهاد؛ رزرو **مسدود نمی‌شود** |
| پروتکل با `session_count = 1` | `422` — همان نوبت تکی است |
| `params` با مقدار آرایه | `422` — فقط اسکالر |
| حذف پروتکلی که دورهٔ فعال دارد | `422` (FK RESTRICT) — `active=false` مسیر درست |
| دوره روی سرویسی که `bookable=false` شد | جلسات موجود می‌مانند؛ جلسهٔ جدید رزرو نمی‌شود، پیام روشن |
سطر «abandon» عمدی است: لغو خودکار هشت نوبت آیندهٔ بیمار بدون تأیید صریح، عملی
برگشت‌ناپذیر روی داده و ظرفیت کلینیک است. کاربر باید خودش تصمیم بگیرد.
## ۱۰. تست
```
tests/Course/CourseStarterTest.php
- ۸ جلسهٔ planned با params درست
- سرویس بدون پروتکل → 422
- دورهٔ فعال دوم → 422 با meta
tests/Course/ProtocolSnapshotTest.php ← ⭐ قانون پنجم
tests/Course/CourseSchedulerTest.php ← ⭐
- book-all: لنگر متحرک (فاصله از جلسهٔ قبلی، نه از شروع)
- نزدیک‌ترین به ایده‌آل انتخاب می‌شود، نه اولین
- شکست جلسهٔ N → rollback همهٔ ۱..N-1
- سقف ۹۰ روز → جلسات باقی planned + پیام
tests/Course/NextSuggestionTest.php
- لنگر = آخرین completed، نه booked
- عبور از max → warning
tests/Course/CourseProgressTest.php
- completed/total/next_session_number/next_params
tests/Course/SameResourcePreferenceTest.php
- منبع جلسهٔ اول ترجیح داده می‌شود
- منبع مشغول → fallback به least_gap، بدون خطا
tests/Course/CoursePolicyInteractionTest.php
- قانون سخت‌گیرانه‌تر برنده
- بازهٔ تهی → 422 روشن
tests/Course/CoursePackageTest.php
- هر جلسه یک واحد مصرف
- اعتبار کمتر از جلسات → warning نه error
tests/Course/CourseLifecycleTest.php
- لغو وسط دوره · no_show → skipped · جلسهٔ آخر → completed خودکار
- abandon نوبت‌های booked را لغو نمی‌کند
```
## ۱۱. مستندات
`docs/api/course.md` بساز. حتماً بنویس: قاعدهٔ «سخت‌گیرانه‌تر برنده» بین پروتکل و قانون،
رفتار سقف ۹۰ روز، و اینکه `abandon` نوبت‌ها را لغو نمی‌کند.
@@ -1,78 +0,0 @@
# تسک ۱۲ — دوره درمان
**فاز:** ۳ (کسب‌وکار) · **وابستگی:** ۰۷، ۱۱ · **زمان:** ۱۶-۲۰ ساعت
---
## هدف
مستند بند ۱۳: «لیزر معمولاً شش تا هشت جلسه است. طراحی قبلی فقط نوبت تکی می‌شناخت، در
حالی که این حالت اصلی کسب‌وکار است.»
## وضعیت فعلی
هیچ مفهومی از دوره وجود ندارد. `PatientSession` وجود دارد ولی «مراجعهٔ انجام‌شده» است،
نه جلسهٔ برنامه‌ریزی‌شدهٔ یک دوره. تسک ۰۴ ستون `session_count` را به `ServiceItem` اضافه
کرده ولی هیچ رفتاری به آن وصل نیست.
## دامنه
**هست:**
- `CourseProtocol` — پروتکل دوره: تعداد جلسه، فاصلهٔ حداقل/ایده‌آل/حداکثر، پارامتر هر جلسه
- `TreatmentCourse` — دورهٔ یک بیمار
- `CourseSession` — جلسات دوره (برنامه‌ریزی‌شده یا انجام‌شده)
- رزرو کل دوره یکجا، یا جلسه‌به‌جلسه
- پیشنهاد تاریخ جلسهٔ بعدی
- هشدار عبور از حداکثر فاصله
- ردیابی پیشرفت («جلسهٔ ۳ از ۸»)
- ترجیح **همان منبع قبلی** (استراتژی `same_as_previous` تسک ۰۶)
**نیست:** موتور قانون فاصله (تسک ۰۹ — `spacing` از آن استفاده می‌شود)، پکیج (تسک ۱۱ —
اتصال دارد ولی مستقل است).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/v1/course-protocols` | پروتکل دوره per سرویس |
| GET/PATCH/DELETE | `/api/v1/course-protocol/{uuid}` | |
| POST | `/api/v1/treatment-course` | شروع دوره برای بیمار |
| GET | `/api/v1/treatment-course/{uuid}` | جزئیات + جلسات + پیشرفت |
| GET | `/api/v1/patient/{uuid}/courses` | دوره‌های بیمار |
| POST | `/api/v1/treatment-course/{uuid}/book-all` | رزرو همهٔ جلسات باقی‌مانده |
| GET | `/api/v1/treatment-course/{uuid}/next-slot-suggestion` | پیشنهاد تاریخ جلسهٔ بعدی |
| POST | `/api/v1/treatment-course/{uuid}/abandon` | رهاکردن دوره با دلیل |
## معیار پذیرش
- ✅ موفق: پروتکل «لیزر فول‌بادی: ۸ جلسه، حداقل ۲۱ / ایده‌آل ۲۸ / حداکثر ۴۵ روز،
سطح انرژی ۱۲،۱۴،۱۶،۱۸،۲۰،۲۰،۲۲،۲۲» تعریف می‌شود →
`POST /treatment-course` هشت `CourseSession` با وضعیت `planned` می‌سازد و پارامتر هر
جلسه را از پروتکل کپی می‌کند.
- ✅ موفق: `POST /book-all` → هشت نوبت با فاصلهٔ ایده‌آل ۲۸ روز رزرو می‌شود؛ هر جلسه به
`CourseSession` متناظر لینک می‌شود. اگر روز ایده‌آل ظرفیت نداشت، **نزدیک‌ترین روز داخل
بازهٔ حداقل..حداکثر** انتخاب می‌شود.
- ✅ موفق: بعد از انجام جلسهٔ ۳، `GET /next-slot-suggestion` تاریخ ۲۸ روز بعد از **جلسهٔ ۳**
را پیشنهاد می‌دهد (نه از شروع دوره).
- ✅ موفق: بیمار ۵۰ روز از جلسهٔ قبل گذشته → پاسخ شامل
`warning: 'از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است'`.
- ✅ موفق: جلسهٔ ۲ به بعد، `same_as_previous` اپراتور جلسهٔ ۱ را انتخاب می‌کند اگر آزاد باشد.
- ✅ موفق: پیشرفت — `GET /treatment-course/{uuid}` می‌دهد
`{ completed: 3, total: 8, next_session_number: 4, next_params: { energy: 18 } }`.
- ❌ خطا: `book-all` وقتی برای یکی از جلسات هیچ وقتی نیست → **هیچ‌کدام رزرو نمی‌شود**،
`422` با شمارهٔ جلسهٔ مشکل‌دار. رزرو نیمه‌کاره ممنوع.
- ❌ خطا: شروع دوره برای سرویسی که پروتکل ندارد → `422`.
- ⚠️ مرزی: بیمار دورهٔ فعال دیگری برای همان سرویس دارد → `422` با لینک به دورهٔ موجود.
- ⚠️ مرزی: لغو یک جلسهٔ وسط دوره → آن `CourseSession` به `planned` برمی‌گردد، بقیه
دست‌نخورده؛ پیشنهاد بعدی مبنایش آخرین جلسهٔ **انجام‌شده** است.
- ⚠️ مرزی: دورهٔ متصل به پکیج (تسک ۱۱) → هر جلسه یک واحد اعتبار مصرف می‌کند.
- ⚠️ مرزی: تعداد جلسات پروتکل تغییر کرد → دوره‌های فعال دست‌نخورده (snapshot).
- ⚠️ مرزی: `book-all` بیشتر از بازهٔ ۹۰ روزهٔ مجاز (۸ جلسه × ۲۸ روز = ۲۲۴ روز) →
فقط جلساتی که در ۹۰ روز جا می‌شوند رزرو شوند، بقیه `planned` بمانند + پیام روشن.
## خروجی
- `src/Course/`
- `assets/admin/pages/CourseProtocolsPage.tsx` + `TreatmentCoursePage.tsx`
- کارت «دوره‌های درمان» در `PatientDetailPage.tsx`
- `docs/api/course.md`
@@ -1,149 +0,0 @@
# جریان کاربری — تسک ۱۲
## الف) کلینیک پروتکل دوره را تعریف می‌کند
```
پنل › خدمات › لیزر فول‌بادی › تب «پروتکل دوره»
تعداد جلسات: ۸
فاصلهٔ حداقل / ایده‌آل / حداکثر: ۲۱ / ۲۸ / ۴۵ روز
☑ تلاش برای انتخاب همان اپراتور جلسات قبل
پارامتر هر جلسه:
┌──────┬──────────────┬────────────┐
│ جلسه │ سطح انرژی │ مدت خاص │
├──────┼──────────────┼────────────┤
│ ۱ │ ۱۲ │ ۷۵ دقیقه │ ← جلسهٔ اول طولانی‌تر (تست و آموزش)
│ ۲ │ ۱۴ │ — │
│ … │ … │ — │
│ ۸ │ ۲۲ │ — │
└──────┴──────────────┴────────────┘
POST /api/v1/course-protocols
```
---
## ب) شروع دوره و رزرو کل آن
```
پنل › بیمار › «شروع دورهٔ درمان»
سرویس: لیزر فول‌بادی (پروتکل خودکار بارگذاری می‌شود)
پکیج: «۶ جلسه لیزر» ▾ (اختیاری — تسک ۱۱)
POST /api/v1/treatment-course
→ ۸ CourseSession با وضعیت «برنامه‌ریزی‌شده»
⚠️ «اعتبار پکیج (۶) کمتر از جلسات دوره (۸) است»
صفحهٔ دوره:
┌────────────────────────────────────────────────────────┐
│ لیزر فول‌بادی — ز. احمدی ● دورهٔ فعال │
│ ●●●○○○○○ ۳ از ۸ جلسه │
├──────┬─────────────┬────────┬─────────┬────────────────┤
│ جلسه │ تاریخ │ فاصله │ انرژی │ وضعیت │
├──────┼─────────────┼────────┼─────────┼────────────────┤
│ ۱ │ ۱۴۰۵/۰۳/۰۵ │ — │ ۱۲ │ ✔ انجام‌شده │
│ ۲ │ ۱۴۰۵/۰۴/۰۲ │ ۲۸ روز │ ۱۴ │ ✔ انجام‌شده │
│ ۳ │ ۱۴۰۵/۰۵/۰۳ │ ۳۱ روز │ ۱۶ │ ✔ انجام‌شده │
│ ۴ │ ۱۴۰۵/۰۵/۳۱ │ ۲۸ روز │ ۱۸ │ ◷ رزروشده │
│ ۵ │ — │ — │ ۲۰ │ ○ برنامه‌ریزی‌شده│
└──────┴─────────────┴────────┴─────────┴────────────────┘
[رزرو جلسهٔ بعدی] [رزرو همهٔ جلسات باقی‌مانده]
```
ستون «فاصله» عدد واقعی است، نه ایده‌آل. کلینیک از آن می‌فهمد بیمار منظم است یا نه.
```
«رزرو همهٔ جلسات باقی‌مانده»
POST /treatment-course/{uuid}/book-all
├─ لنگر: تاریخ جلسهٔ ۴ (آخرین رزروشده)
├─ جلسهٔ ۵: هدف ۲۸ روز بعد → نزدیک‌ترین وقت در بازهٔ ۲۱..۴۵ روز
├─ جلسهٔ ۶: لنگر = تاریخ واقعی جلسهٔ ۵
├─ جلسهٔ ۷: خارج از ۹۰ روز → planned می‌ماند
└─ همه در یک تراکنش
200 { "booked_count": 2, "remaining_planned": 2,
"message": "۲ جلسه رزرو شد. جلسات ۷ و ۸ خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند." }
```
---
## ج) پیشنهاد جلسهٔ بعدی بعد از هر جلسه
```
منشی وضعیت جلسهٔ ۳ را «انجام‌شده» می‌کند
سیستم خودکار بنر نشان می‌دهد:
┌────────────────────────────────────────────────┐
│ 📅 جلسهٔ بعدی این بیمار │
│ جلسهٔ ۴ از ۸ · سطح انرژی: ۱۸ │
│ تاریخ پیشنهادی: ۱۴۰۵/۰۵/۳۱ (۲۸ روز بعد) │
│ بازهٔ مجاز: ۱۴۰۵/۰۵/۲۴ تا ۱۴۰۵/۰۶/۱۷ │
│ │
│ ۰۹:۰۰ ▸ ۱۱:۳۰ ▸ ۱۴:۰۰ ▸ │
│ [رزرو با اپراتور مریم]│
└────────────────────────────────────────────────┘
```
«اپراتور مریم» چون جلسات ۱ تا ۳ با او بود (`same_as_previous`). اگر آزاد نباشد، نامش
عوض می‌شود و رزرو رد نمی‌شود.
---
## د) بیمار دیر می‌آید — عبور از حداکثر فاصله
```
۶۰ روز از جلسهٔ ۳ گذشته (حداکثر ۴۵ روز)
GET /treatment-course/{uuid}/next-slot-suggestion
{
"session_number": 4,
"warning": "از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.",
"suggested_slots": [ … ]
}
```
در UI یک نوار زرد بالای پیشنهادها. **رزرو مسدود نمی‌شود** — تصمیم بالینی است، نه فنی.
اگر کلینیکی می‌خواهد واقعاً مسدود شود، آن یک قانون `eligibility` است (تسک ۰۹).
---
## ه) لغو جلسهٔ وسط دوره
```
جلسهٔ ۴ لغو می‌شود
├─ Appointment → cancelled_*
├─ CourseSession ۴ → planned ، appointment_id → NULL
├─ اعتبار پکیج → refund +1 (تسک ۱۱)
├─ اشغال منابع → released (تسک ۰۷)
└─ جلسات ۵..۸ دست‌نخورده
پیشنهاد بعدی: لنگر همان جلسهٔ ۳ (آخرین انجام‌شده)
```
جلسات بعدی خودکار جابه‌جا **نمی‌شوند**. جابه‌جایی زنجیره‌ای پنج نوبت آیندهٔ بیمار بدون
تأیید، همان مسئلهٔ `abandon` است: عمل برگشت‌ناپذیر روی داده و ظرفیت.
پنل یک پیشنهاد نشان می‌دهد: «فاصلهٔ جلسات ۵ تا ۸ با لغو این جلسه از پروتکل خارج شد.
[بازچینی جلسات باقی‌مانده]» — با یک کلیک صریح.
---
## و) پایان دوره
```
جلسهٔ ۸ → completed
├─ TreatmentCourse → completed ، completed_at = now
├─ active_course_key → NULL (بیمار می‌تواند دورهٔ جدید شروع کند)
└─ رویداد CourseCompleted (تسک ۱۴)
کارت بیمار: «دورهٔ لیزر فول‌بادی تکمیل شد — ۸ جلسه در ۲۳۱ روز»
[شروع دورهٔ نگهدارنده]
```