diff --git a/docs/api/README.md b/docs/api/README.md index 9e3ff125..f65dfd50 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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 | diff --git a/docs/api/course.md b/docs/api/course.md deleted file mode 100644 index 3f952d5f..00000000 --- a/docs/api/course.md +++ /dev/null @@ -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` وقتی دوره پکیجی ندارد). - -کسری، دوره را باطل نمی‌کند و خطا هم نیست: بقیهٔ جلسات با قیمت عادی حساب می‌شوند. ولی -باید پیش از جلسهٔ ششم دانسته شود نه سرِ آن، پس صفحهٔ دوره آن را به‌صورت هشدار نشان می‌دهد. diff --git a/docs/api/treatment.md b/docs/api/treatment.md new file mode 100644 index 00000000..06638f9b --- /dev/null +++ b/docs/api/treatment.md @@ -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 } +``` diff --git a/docs/new_feture/taskes/task-12-treatment-course/architecture.md b/docs/new_feture/taskes/task-12-treatment-course/architecture.md deleted file mode 100644 index 1d0695e5..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/architecture.md +++ /dev/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` -- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از - همان می‌فهمد بیمار منظم است یا نه diff --git a/docs/new_feture/taskes/task-12-treatment-course/checklist.md b/docs/new_feture/taskes/task-12-treatment-course/checklist.md deleted file mode 100644 index 4df2c7cb..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/checklist.md +++ /dev/null @@ -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 .` | ✅ | دو کامیت جدا | -| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ | diff --git a/docs/new_feture/taskes/task-12-treatment-course/database.md b/docs/new_feture/taskes/task-12-treatment-course/database.md deleted file mode 100644 index bec0a479..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/database.md +++ /dev/null @@ -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` | diff --git a/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md b/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md deleted file mode 100644 index d4e3c9be..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md +++ /dev/null @@ -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` نوبت‌ها را لغو نمی‌کند. diff --git a/docs/new_feture/taskes/task-12-treatment-course/task.md b/docs/new_feture/taskes/task-12-treatment-course/task.md deleted file mode 100644 index b232e468..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/task.md +++ /dev/null @@ -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` diff --git a/docs/new_feture/taskes/task-12-treatment-course/user_flow.md b/docs/new_feture/taskes/task-12-treatment-course/user_flow.md deleted file mode 100644 index 83ebcfa5..00000000 --- a/docs/new_feture/taskes/task-12-treatment-course/user_flow.md +++ /dev/null @@ -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 (تسک ۱۴) - ▼ -کارت بیمار: «دورهٔ لیزر فول‌بادی تکمیل شد — ۸ جلسه در ۲۳۱ روز» - [شروع دورهٔ نگهدارنده] -``` diff --git a/migrations/Version20260806124440.php b/migrations/Version20260806124440.php new file mode 100644 index 00000000..c61f2782 --- /dev/null +++ b/migrations/Version20260806124440.php @@ -0,0 +1,80 @@ +addSql(<<<'SQL' + CREATE TABLE treatment_protocols ( + id INT AUTO_INCREMENT NOT NULL, + uuid VARCHAR(36) NOT NULL, + service_item_id INT NOT NULL, + supervisor_doctor_id INT DEFAULT NULL, + active TINYINT DEFAULT 1 NOT NULL, + created_at INT NOT NULL, + updated_at INT NOT NULL, + UNIQUE INDEX UNIQ_76894114D17F50A6 (uuid), + UNIQUE INDEX uq_treatment_protocol_service (service_item_id), + INDEX IDX_76894114EB665C09 (supervisor_doctor_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE treatment_protocol_steps ( + id INT AUTO_INCREMENT NOT NULL, + protocol_id INT NOT NULL, + step_number SMALLINT NOT NULL, + offset_days SMALLINT NOT NULL, + created_at INT NOT NULL, + UNIQUE INDEX uq_protocol_step_number (protocol_id, step_number), + INDEX IDX_65053F5ACCD59258 (protocol_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE treatment_protocol_staff ( + id INT AUTO_INCREMENT NOT NULL, + protocol_id INT NOT NULL, + staff_id INT NOT NULL, + UNIQUE INDEX uq_protocol_staff (protocol_id, staff_id), + INDEX IDX_1349C6BACCD59258 (protocol_id), + INDEX IDX_1349C6BAD4D57CD (staff_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 + SQL); + + // حذف سرویس، پروتکلش را هم می‌برد: پروتکلِ بی‌سرویس معنایی ندارد. + $this->addSql('ALTER TABLE treatment_protocols ADD CONSTRAINT FK_76894114DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + // ولی رفتن پزشک ناظر نباید دوره را حذف کند — فقط ناظرش خالی می‌شود. + $this->addSql('ALTER TABLE treatment_protocols ADD CONSTRAINT FK_76894114EB665C09 FOREIGN KEY (supervisor_doctor_id) REFERENCES doctors (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_protocol_steps ADD CONSTRAINT FK_65053F5ACCD59258 FOREIGN KEY (protocol_id) REFERENCES treatment_protocols (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE treatment_protocol_staff ADD CONSTRAINT FK_1349C6BACCD59258 FOREIGN KEY (protocol_id) REFERENCES treatment_protocols (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE treatment_protocol_staff ADD CONSTRAINT FK_1349C6BAD4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE treatment_protocol_staff DROP FOREIGN KEY FK_1349C6BACCD59258'); + $this->addSql('ALTER TABLE treatment_protocol_staff DROP FOREIGN KEY FK_1349C6BAD4D57CD'); + $this->addSql('ALTER TABLE treatment_protocol_steps DROP FOREIGN KEY FK_65053F5ACCD59258'); + $this->addSql('ALTER TABLE treatment_protocols DROP FOREIGN KEY FK_76894114DDEB00C2'); + $this->addSql('ALTER TABLE treatment_protocols DROP FOREIGN KEY FK_76894114EB665C09'); + $this->addSql('DROP TABLE treatment_protocol_staff'); + $this->addSql('DROP TABLE treatment_protocol_steps'); + $this->addSql('DROP TABLE treatment_protocols'); + } +} diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 811cee14..705474d5 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -90,7 +90,13 @@ class ServiceItem #[ORM\Column(name: 'additional_duration_minutes', type: 'smallint', nullable: true)] private ?int $additionalDurationMinutes = null; - /** تعداد جلسات؛ ۱ یعنی تک‌جلسه‌ای. پروتکل کامل دوره در تسک ۱۲. */ + /** + * @deprecated تعداد جلسات از `TreatmentProtocol::totalSessions()` می‌آید. + * + * این ستون هیچ‌وقت منطقی پشتش نداشت و فقط در پاسخ‌ها دیده می‌شد. برای نشکستن + * کلاینت‌ها در `toArray()` می‌ماند، ولی هیچ کد جدیدی نباید بخواندش: پروتکل و این + * عدد دو منبع حقیقت برای یک مفهوم‌اند و اولی مرجع است. + */ #[ORM\Column(name: 'session_count', type: 'smallint', options: ['default' => 1])] private int $sessionCount = 1; diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index c153acfa..3897b406 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -111,6 +111,12 @@ final class GlobalTables \App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class, + + // «طول درمانِ این سرویس» جزئی از تعریف همان سرویس است و uuid خودش هیچ‌جا از + // درخواست نمی‌آید — تنها راه رسیدن به آن، uuid سرویس است. + \App\Treatment\Entity\TreatmentProtocol::class => \App\ClinicService\Entity\ServiceItem::class, + \App\Treatment\Entity\TreatmentProtocolStep::class => \App\Treatment\Entity\TreatmentProtocol::class, + \App\Treatment\Entity\TreatmentProtocolStaff::class => \App\Treatment\Entity\TreatmentProtocol::class, // یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال // در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند. \App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class, diff --git a/src/Treatment/Controller/TreatmentProtocolController.php b/src/Treatment/Controller/TreatmentProtocolController.php new file mode 100644 index 00000000..85b96568 --- /dev/null +++ b/src/Treatment/Controller/TreatmentProtocolController.php @@ -0,0 +1,85 @@ +protocols->findForService($this->requireItem($user, $uuid)); + + return $this->success($protocol?->toArray()); + } + + #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_replace', methods: ['PUT'])] + public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data); + + return $this->success($protocol->toArray()); + } + + /** خاموش کردن سوییچ: پروتکل حذف می‌شود و سرویس دوباره تک‌جلسه‌ای می‌گردد. */ + #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $protocol = $this->protocols->findForService($this->requireItem($user, $uuid)); + + if ($protocol !== null) { + $this->em->remove($protocol); + $this->em->flush(); + } + + return $this->success(null); + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } +} diff --git a/src/Treatment/Entity/TreatmentProtocol.php b/src/Treatment/Entity/TreatmentProtocol.php new file mode 100644 index 00000000..358d3e15 --- /dev/null +++ b/src/Treatment/Entity/TreatmentProtocol.php @@ -0,0 +1,152 @@ + true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + #[ORM\OneToMany(targetEntity: TreatmentProtocolStep::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['stepNumber' => 'ASC'])] + private Collection $steps; + + #[ORM\OneToMany(targetEntity: TreatmentProtocolStaff::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $allowedStaff; + + public function __construct(ServiceItem $serviceItem) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->serviceItem = $serviceItem; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->steps = new ArrayCollection(); + $this->allowedStaff = new ArrayCollection(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getSupervisorDoctor(): ?Doctor { return $this->supervisorDoctor; } + public function isActive(): bool { return $this->active; } + public function getSteps(): Collection { return $this->steps; } + public function getAllowedStaff(): Collection { return $this->allowedStaff; } + + public function setSupervisorDoctor(?Doctor $v): self { $this->supervisorDoctor = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + /** تعداد جلسات یک دوره — همیشه از گام‌ها می‌آید، نه از `ServiceItem::$sessionCount`. */ + public function totalSessions(): int + { + return $this->steps->count(); + } + + /** @param TreatmentProtocolStep[] $steps */ + public function replaceSteps(array $steps): self + { + $this->steps->clear(); + foreach ($steps as $step) { + $this->steps->add($step); + } + $this->touch(); + + return $this; + } + + /** @param TreatmentProtocolStaff[] $members */ + public function replaceAllowedStaff(array $members): self + { + $this->allowedStaff->clear(); + foreach ($members as $member) { + $this->allowedStaff->add($member); + } + $this->touch(); + + return $this; + } + + public function allows(ClinicStaff $staff): bool + { + foreach ($this->allowedStaff as $member) { + if ($member->getStaff()->getId() === $staff->getId()) { + return true; + } + } + + return false; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'service_uuid' => $this->serviceItem->getUuid(), + 'active' => $this->active, + 'total_sessions' => $this->totalSessions(), + 'supervisor' => $this->supervisorDoctor === null ? null : [ + 'uuid' => $this->supervisorDoctor->getUuid(), + 'name' => $this->supervisorDoctor->getName(), + ], + 'steps' => array_map( + static fn (TreatmentProtocolStep $s): array => $s->toArray(), + $this->steps->toArray(), + ), + 'staff' => array_map( + static fn (TreatmentProtocolStaff $m): array => $m->toArray(), + $this->allowedStaff->toArray(), + ), + ]; + } + + private function touch(): void { $this->updatedAt = time(); } +} diff --git a/src/Treatment/Entity/TreatmentProtocolStaff.php b/src/Treatment/Entity/TreatmentProtocolStaff.php new file mode 100644 index 00000000..b76d9c7d --- /dev/null +++ b/src/Treatment/Entity/TreatmentProtocolStaff.php @@ -0,0 +1,50 @@ +protocol = $protocol; + $this->staff = $staff; + } + + public function getId(): ?int { return $this->id; } + public function getProtocol(): TreatmentProtocol { return $this->protocol; } + public function getStaff(): ClinicStaff { return $this->staff; } + + public function toArray(): array + { + return [ + 'uuid' => $this->staff->getUuid(), + 'name' => $this->staff->getFullName(), + ]; + } +} diff --git a/src/Treatment/Entity/TreatmentProtocolStep.php b/src/Treatment/Entity/TreatmentProtocolStep.php new file mode 100644 index 00000000..f9458236 --- /dev/null +++ b/src/Treatment/Entity/TreatmentProtocolStep.php @@ -0,0 +1,58 @@ +protocol = $protocol; + $this->stepNumber = $stepNumber; + $this->offsetDays = $offsetDays; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getProtocol(): TreatmentProtocol { return $this->protocol; } + public function getStepNumber(): int { return $this->stepNumber; } + public function getOffsetDays(): int { return $this->offsetDays; } + + public function toArray(): array + { + return [ + 'step_number' => $this->stepNumber, + 'offset_days' => $this->offsetDays, + ]; + } +} diff --git a/src/Treatment/Repository/TreatmentProtocolRepository.php b/src/Treatment/Repository/TreatmentProtocolRepository.php new file mode 100644 index 00000000..866e7cb7 --- /dev/null +++ b/src/Treatment/Repository/TreatmentProtocolRepository.php @@ -0,0 +1,30 @@ + + */ +class TreatmentProtocolRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TreatmentProtocol::class); + } + + public function findForService(ServiceItem $service): ?TreatmentProtocol + { + return $this->findOneBy(['serviceItem' => $service]); + } + + /** پروتکلِ فعالِ یک سرویس — نقطهٔ تصمیمِ «این نوبت دوره‌ای است یا تک‌جلسه‌ای». */ + public function findActiveForService(ServiceItem $service): ?TreatmentProtocol + { + return $this->findOneBy(['serviceItem' => $service, 'active' => true]); + } +} diff --git a/src/Treatment/Repository/TreatmentProtocolStaffRepository.php b/src/Treatment/Repository/TreatmentProtocolStaffRepository.php new file mode 100644 index 00000000..e829d78d --- /dev/null +++ b/src/Treatment/Repository/TreatmentProtocolStaffRepository.php @@ -0,0 +1,18 @@ + + */ +class TreatmentProtocolStaffRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TreatmentProtocolStaff::class); + } +} diff --git a/src/Treatment/Repository/TreatmentProtocolStepRepository.php b/src/Treatment/Repository/TreatmentProtocolStepRepository.php new file mode 100644 index 00000000..aa9dc390 --- /dev/null +++ b/src/Treatment/Repository/TreatmentProtocolStepRepository.php @@ -0,0 +1,18 @@ + + */ +class TreatmentProtocolStepRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TreatmentProtocolStep::class); + } +} diff --git a/src/Treatment/Service/TreatmentProtocolWriter.php b/src/Treatment/Service/TreatmentProtocolWriter.php new file mode 100644 index 00000000..127c33e8 --- /dev/null +++ b/src/Treatment/Service/TreatmentProtocolWriter.php @@ -0,0 +1,180 @@ +readSteps($data); + $staffUuids = $this->readStaffUuids($data); + $supervisor = is_string($data['supervisor_doctor_uuid'] ?? null) && $data['supervisor_doctor_uuid'] !== '' + ? $this->context->supervisor($user, $data['supervisor_doctor_uuid']) + : null; + + [$entityType, $entityId] = $this->context->pair($user); + + $members = []; + foreach ($staffUuids as $uuid) { + $member = $this->staff->findByUuid($uuid); + + // پرسنل با uuid از خودِ درخواست می‌آید و TenantFilter پوششش نمی‌دهد؛ بدون + // این بررسی، پرسنلِ کلینیک دیگری روی پروتکل این کلینیک می‌نشست. + if (!$this->ownership->belongsToPair($entityType, $entityId, $member) || !$member->isActive()) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پرسنل یافت نشد', 404, 'staff_uuids'); + } + + $members[] = $member; + } + + $protocol = $this->protocols->findForService($service) ?? new TreatmentProtocol($service); + + return $this->em->wrapInTransaction(function () use ($protocol, $supervisor, $steps, $members): TreatmentProtocol { + $protocol->setSupervisorDoctor($supervisor); + $protocol->setActive(true); + $this->em->persist($protocol); + + // خالی‌کردن و پرکردن در **دو** flush: در یک flush واحد، Doctrine درج‌ها را + // پیش از حذف‌ها می‌فرستد و ردیف تازه به قید یکتای (protocol, step_number) + // می‌خورد. تراکنش تضمین می‌کند حالت میانیِ «پروتکلِ بی‌گام» دیده نشود. + $protocol->replaceSteps([]); + $protocol->replaceAllowedStaff([]); + $this->em->flush(); + + $protocol->replaceSteps(array_map( + static fn (array $step): TreatmentProtocolStep + => new TreatmentProtocolStep($protocol, $step['step_number'], $step['offset_days']), + $steps, + )); + + $protocol->replaceAllowedStaff(array_map( + static fn ($member): TreatmentProtocolStaff => new TreatmentProtocolStaff($protocol, $member), + $members, + )); + + $this->em->flush(); + + return $protocol; + }); + } + + /** + * @return list + */ + private function readSteps(array $data): array + { + $rows = $data['steps'] ?? null; + + if (!is_array($rows)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد steps الزامی است', 422, 'steps'); + } + + if (count($rows) < TreatmentProtocol::MIN_STEPS) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('دورهٔ درمان حداقل %d جلسه دارد؛ کمتر از آن یعنی سرویس تک‌جلسه‌ای', TreatmentProtocol::MIN_STEPS), + 422, + 'steps', + ); + } + + if (count($rows) > TreatmentProtocol::MAX_STEPS) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('دورهٔ درمان حداکثر %d جلسه دارد', TreatmentProtocol::MAX_STEPS), + 422, + 'steps', + ); + } + + $steps = []; + + foreach (array_values($rows) as $index => $row) { + $expected = $index + 1; + + if (!is_array($row) || !is_numeric($row['offset_days'] ?? null)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'offset_days هر گام الزامی است', 422, 'offset_days'); + } + + $number = isset($row['step_number']) ? (int) $row['step_number'] : $expected; + $offset = (int) $row['offset_days']; + + // شماره‌ها باید پیوسته از ۱ باشند: «جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که + // گامی جا نیفتاده باشد. + if ($number !== $expected) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('شمارهٔ گام‌ها باید پیوسته از ۱ باشد؛ گام %d انتظار می‌رفت', $expected), + 422, + 'step_number', + ); + } + + // گام اول لنگر دوره است و فاصله‌ای از «جلسهٔ قبل» ندارد. + if ($expected === 1 && $offset !== 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فاصلهٔ گام اول باید صفر باشد', 422, 'offset_days'); + } + + if ($expected > 1 && $offset <= 0) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('فاصلهٔ گام %d باید بزرگ‌تر از صفر باشد', $expected), + 422, + 'offset_days', + ); + } + + $steps[] = ['step_number' => $number, 'offset_days' => $offset]; + } + + return $steps; + } + + /** @return list */ + private function readStaffUuids(array $data): array + { + $rows = $data['staff_uuids'] ?? null; + + if (!is_array($rows) || $rows === []) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'حداقل یک پرسنل مجاز الزامی است', 422, 'staff_uuids'); + } + + $uuids = []; + foreach ($rows as $uuid) { + if (!is_string($uuid) || trim($uuid) === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'uuid پرسنل نامعتبر است', 422, 'staff_uuids'); + } + + $uuids[trim($uuid)] = true; + } + + return array_keys($uuids); + } +} diff --git a/tests/Treatment/TreatmentProtocolTest.php b/tests/Treatment/TreatmentProtocolTest.php new file mode 100644 index 00000000..76dbe9c8 --- /dev/null +++ b/tests/Treatment/TreatmentProtocolTest.php @@ -0,0 +1,279 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک زیبایی آزمون'); + $this->em->persist($clinic); + $this->em->flush(); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $service = new ServiceItem($section, 'لیزر فول بادی', 10_000_000); + $this->em->persist($service); + $this->em->flush(); + + return [$user, $clinic, $service, $address]; + } + + private function staffFor(Clinic $clinic, string $name): ClinicStaff + { + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name); + $this->em->persist($staff); + $this->em->flush(); + + return $staff; + } + + private function doctorIn(Clinic $clinic): Doctor + { + $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر ناظر'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + return $doctor; + } + + /** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه — فاصلهٔ ثابت این را نمی‌گیرد. */ + public function testProtocolWithUnevenIntervalsIsStoredAndReadBack(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + $supervisor = $this->doctorIn($clinic); + $uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol'; + + $body = $this->authJson('PUT', $uri, $user, [ + 'supervisor_doctor_uuid' => $supervisor->getUuid(), + 'staff_uuids' => [$staff->getUuid()], + '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], + ], + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(4, $body['data']['total_sessions']); + self::assertSame([0, 15, 30, 30], array_column($body['data']['steps'], 'offset_days')); + self::assertSame($supervisor->getUuid(), $body['data']['supervisor']['uuid']); + self::assertSame([$staff->getUuid()], array_column($body['data']['staff'], 'uuid')); + + $read = $this->authJson('GET', $uri, $user); + self::assertSame([0, 15, 30, 30], array_column($read['data']['steps'], 'offset_days')); + } + + /** سرویس بدون پروتکل یعنی سوییچ خاموش، نه «پیدا نشد». */ + public function testServiceWithoutProtocolReturnsNull(): void + { + [$user, , $service] = $this->clinicWithService(); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user); + + self::assertSame(200, $this->responseCode()); + self::assertNull($body['data']); + } + + public function testReplacingAProtocolDropsTheOldSteps(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + $uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol'; + + $this->authJson('PUT', $uri, $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 30], + ['step_number' => 3, 'offset_days' => 30], + ], + ]); + + $body = $this->authJson('PUT', $uri, $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 21], + ], + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(2, $body['data']['total_sessions']); + self::assertSame([0, 21], array_column($body['data']['steps'], 'offset_days')); + } + + public function testDeleteTurnsTheSwitchOff(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + $uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol'; + + $this->authJson('PUT', $uri, $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 30], + ], + ]); + + $this->authJson('DELETE', $uri, $user); + self::assertSame(200, $this->responseCode()); + + $body = $this->authJson('GET', $uri, $user); + self::assertNull($body['data']); + } + + /** یک گام یعنی سرویس تک‌جلسه‌ای — یعنی سوییچ اصلاً نباید روشن می‌شد. */ + public function testSingleStepProtocolIsRejected(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [['step_number' => 1, 'offset_days' => 0]], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('steps', $body['errors'][0]['field']); + } + + public function testFirstStepMustHaveZeroOffset(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 10], + ['step_number' => 2, 'offset_days' => 30], + ], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('offset_days', $body['errors'][0]['field']); + } + + public function testLaterStepsNeedAPositiveOffset(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 0], + ], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('offset_days', $body['errors'][0]['field']); + } + + /** «جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که گامی جا نیفتاده باشد. */ + public function testStepNumbersMustBeContiguous(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 3, 'offset_days' => 30], + ], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('step_number', $body['errors'][0]['field']); + } + + public function testStaffIsRequired(): void + { + [$user, , $service] = $this->clinicWithService(); + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 30], + ], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('staff_uuids', $body['errors'][0]['field']); + } + + /** پرسنل با uuid از خودِ درخواست می‌آید و TenantFilter پوششش نمی‌دهد. */ + public function testStaffFromAnotherEnvironmentIsRejected(): void + { + [$user, , $service] = $this->clinicWithService(); + [, $otherClinic] = $this->clinicWithService(); + $foreignStaff = $this->staffFor($otherClinic, 'اپراتور بیگانه'); + + $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$foreignStaff->getUuid()], + 'steps' => [ + ['step_number' => 1, 'offset_days' => 0], + ['step_number' => 2, 'offset_days' => 30], + ], + ]); + + self::assertSame(404, $this->responseCode()); + } + + public function testServiceFromAnotherEnvironmentIsNotFound(): void + { + [$user] = $this->clinicWithService(); + [, , $otherService] = $this->clinicWithService(); + + $this->authJson('GET', '/api/v1/service-item/' . $otherService->getUuid() . '/treatment-protocol', $user); + + self::assertSame(404, $this->responseCode()); + } + + public function testMaxStepsIsEnforced(): void + { + [$user, $clinic, $service] = $this->clinicWithService(); + $staff = $this->staffFor($clinic, 'اپراتور یک'); + + $steps = [['step_number' => 1, 'offset_days' => 0]]; + for ($i = 2; $i <= TreatmentProtocol::MAX_STEPS + 1; $i++) { + $steps[] = ['step_number' => $i, 'offset_days' => 30]; + } + + $body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [ + 'staff_uuids' => [$staff->getUuid()], + 'steps' => $steps, + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('steps', $body['errors'][0]['field']); + } +}