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