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>
12 KiB
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)
{
"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
{
"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
{
"patient_uuid": "db7bde5a-…",
"protocol_uuid": "6be5047e-…",
"patient_package_uuid": "dcd21f8e-…"
}
patient_package_uuid اختیاری است (تسک ۱۱). پکیجی که این خدمت را پوشش ندهد 422 میگیرد.
Response 201
{
"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 | بیمار، پروتکل یا پکیج خارج از محیط جاری |
{
"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
{
"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
{ "branch_uuid": "…", "doctor_uuid": "…" }
Response 200
{
"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
{ "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 |
تستها
ddev exec php bin/phpunit tests/Course # ۱۴ تست
مهمترینها: testChangingTheProtocolLeavesRunningCoursesAlone (snapshot)،
testTheSuggestionAnchorsOnTheLastCompletedSession (لنگر متحرک) و
testCancellingOneSessionOnlyResetsThatSession.