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>
11 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,
"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 | بیمار، پروتکل یا پکیج خارج از محیط جاری |
{
"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.