From e2e3e6b43bc8eb0be624877d533e7eeb8de701fb Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 16:36:04 +0330 Subject: [PATCH] feat(treatment): add treatment protocols, the multi-session course of a service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A protocol says a course of a service runs over several sessions, when each falls due, which doctor supervises it and which staff may perform it. The row existing IS the "طول درمان" switch, so there is no separate boolean that could disagree with the step list. Each step's offset is measured from the previous session rather than from the start of the course: laser spacing is a clinical requirement — hair regrows relative to the last treatment — so a late patient shifts the rest of their course instead of getting the next session early. That also lets one course use uneven gaps, which a single min/ideal/max triple cannot express: a botox course is session 1, then +15 days, then monthly. Steps and staff are cleared and rewritten in two flushes inside a transaction. A single flush sends inserts before deletes and the replacement row collides with the unique (protocol, step_number) index — caught by the replace test. Removes docs/api/course.md and the task-12 folder. They documented src/Course/, a module deleted in 65d5831c whose commit message only mentions removing two test files; that design is superseded by this one. ServiceItem::$sessionCount is marked deprecated. It never had logic behind it and session count now comes from the protocol; the column stays in payloads so existing clients keep working. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/README.md | 1 + docs/api/course.md | 318 ------------------ docs/api/treatment.md | 143 ++++++++ .../task-12-treatment-course/architecture.md | 213 ------------ .../task-12-treatment-course/checklist.md | 130 ------- .../task-12-treatment-course/database.md | 138 -------- .../implementation_notes.md | 173 ---------- .../taskes/task-12-treatment-course/task.md | 78 ----- .../task-12-treatment-course/user_flow.md | 149 -------- migrations/Version20260806124440.php | 80 +++++ src/ClinicService/Entity/ServiceItem.php | 8 +- src/Shared/Tenant/GlobalTables.php | 6 + .../TreatmentProtocolController.php | 85 +++++ src/Treatment/Entity/TreatmentProtocol.php | 152 +++++++++ .../Entity/TreatmentProtocolStaff.php | 50 +++ .../Entity/TreatmentProtocolStep.php | 58 ++++ .../TreatmentProtocolRepository.php | 30 ++ .../TreatmentProtocolStaffRepository.php | 18 + .../TreatmentProtocolStepRepository.php | 18 + .../Service/TreatmentProtocolWriter.php | 180 ++++++++++ tests/Treatment/TreatmentProtocolTest.php | 279 +++++++++++++++ 21 files changed, 1107 insertions(+), 1200 deletions(-) delete mode 100644 docs/api/course.md create mode 100644 docs/api/treatment.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/architecture.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/checklist.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/database.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/task.md delete mode 100644 docs/new_feture/taskes/task-12-treatment-course/user_flow.md create mode 100644 migrations/Version20260806124440.php create mode 100644 src/Treatment/Controller/TreatmentProtocolController.php create mode 100644 src/Treatment/Entity/TreatmentProtocol.php create mode 100644 src/Treatment/Entity/TreatmentProtocolStaff.php create mode 100644 src/Treatment/Entity/TreatmentProtocolStep.php create mode 100644 src/Treatment/Repository/TreatmentProtocolRepository.php create mode 100644 src/Treatment/Repository/TreatmentProtocolStaffRepository.php create mode 100644 src/Treatment/Repository/TreatmentProtocolStepRepository.php create mode 100644 src/Treatment/Service/TreatmentProtocolWriter.php create mode 100644 tests/Treatment/TreatmentProtocolTest.php 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']); + } +}