feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# Course — دورهٔ درمان
|
||||
|
||||
اندپوینتهای `src/Course/*`. «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی
|
||||
میشناخت، در حالی که دوره حالت اصلی کسبوکار است.
|
||||
|
||||
همهٔ مسیرها `IS_AUTHENTICATED_FULLY` میخواهند و به محیط جاری محدودند (`404` برای محیط دیگر).
|
||||
|
||||
---
|
||||
|
||||
## مفاهیم
|
||||
|
||||
| | چیست |
|
||||
|---|---|
|
||||
| `CourseProtocol` | تعریف دوره per سرویس: تعداد جلسه و سه فاصلهٔ حداقل/ایدهآل/حداکثر |
|
||||
| `CourseProtocolStep` | پارامتر هر جلسه (مثلاً سطح انرژی) — اسکالر و آزاد |
|
||||
| `TreatmentCourse` | دورهٔ یک بیمار؛ چهار عدد پروتکل را **کپی** میکند |
|
||||
| `CourseSession` | جلسات دوره: `planned` → `booked` → `completed` (یا `skipped`) |
|
||||
|
||||
**سه فاصله سه معنا دارند:** `min` زودترین زمان مجاز، `ideal` بهترین، `max` جایی که دیرتر
|
||||
از آن اثر دوره افت میکند. برنامهریز **نزدیکترین وقت به ایدهآل** را میگیرد، نه اولین
|
||||
وقت خالی: روز ۲۱ (حداقل) از نظر درمانی بدتر از روز ۲۷ است.
|
||||
|
||||
**لنگر متحرک:** فاصله همیشه از آخرین جلسهٔ **انجامشده** حساب میشود، نه از شروع دوره. اگر
|
||||
جلسهٔ ۲ سه روز دیرتر افتاد، جلسهٔ ۳ هم جابهجا میشود.
|
||||
|
||||
**snapshot:** تعداد جلسه، سه فاصله و پارامتر هر جلسه در لحظهٔ شروع کپی میشوند. تغییر
|
||||
پروتکل فردا، دورهٔ در جریان را عوض نمیکند (قانون پنجم مستند).
|
||||
|
||||
**یک دورهٔ فعال per (بیمار، سرویس):** با ستون `active_course_key` که در حالتهای
|
||||
`completed`/`abandoned` تهی میشود — همان الگوی `Appointment::activeSlotKey`، چون MariaDB
|
||||
کلید یکتای جزئی ندارد.
|
||||
|
||||
---
|
||||
|
||||
## GET · POST `/api/v1/course-protocols`
|
||||
|
||||
### Request Body (POST)
|
||||
```json
|
||||
{
|
||||
"service_uuid": "acea173f-aa5d-4d1e-bc19-9b5ca7e66234",
|
||||
"session_count": 8,
|
||||
"min_days": 21,
|
||||
"ideal_days": 28,
|
||||
"max_days": 45,
|
||||
"prefer_same_resource": true,
|
||||
"steps": [
|
||||
{ "session_number": 1, "params": { "energy": 12 } },
|
||||
{ "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": 45 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `service_uuid` | string | ✅ | یک پروتکل per سرویس |
|
||||
| `session_count` | int | ✅ | حداقل ۲ — دورهٔ یکجلسهای همان نوبت تکی است |
|
||||
| `min_days` / `ideal_days` / `max_days` | int | ✅ | باید `min ≤ ideal ≤ max` |
|
||||
| `prefer_same_resource` | bool | — | پیشفرض `true` |
|
||||
| `steps` | array | — | جایگزینی کامل؛ `params` فقط اسکالر |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "6be5047e-0a3e-4c55-a637-77b814b3b8e3",
|
||||
"service_uuid": "acea173f-…",
|
||||
"service_name": "لیزر فولبادی",
|
||||
"session_count": 8,
|
||||
"min_days": 21,
|
||||
"ideal_days": 28,
|
||||
"max_days": 45,
|
||||
"prefer_same_resource": true,
|
||||
"active": true,
|
||||
"steps": [
|
||||
{ "session_number": 1, "params": { "energy": 12 }, "override_duration_minutes": null },
|
||||
{ "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": null }
|
||||
],
|
||||
"created_at": 1785484481
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | `session_count < 2`، ترتیب فاصلهها نادرست، سرویسی که از قبل پروتکل دارد، یا `session_number` بیرون بازه |
|
||||
| `ERR_VALIDATION_002` | 422 | `service_uuid` غایب |
|
||||
| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری |
|
||||
|
||||
`PATCH /api/v1/course-protocol/{uuid}` همان فیلدها؛ `DELETE` پروتکل را **غیرفعال** میکند
|
||||
چون دورههای در جریان به آن ارجاع دارند.
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/treatment-course`
|
||||
|
||||
شروع دوره. **همهٔ** جلسات همان لحظه با وضعیت `planned` ساخته میشوند تا بیمار از روز اول
|
||||
ببیند «۸ جلسه» یعنی چه.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"patient_uuid": "db7bde5a-…",
|
||||
"protocol_uuid": "6be5047e-…",
|
||||
"patient_package_uuid": "dcd21f8e-…"
|
||||
}
|
||||
```
|
||||
|
||||
`patient_package_uuid` اختیاری است (تسک ۱۱). پکیجی که این خدمت را پوشش ندهد `422` میگیرد.
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "8088fdd5-f19f-483e-b21b-11118e1b5e29",
|
||||
"patient_uuid": "db7bde5a-…",
|
||||
"service_uuid": "acea173f-…",
|
||||
"service_name": "لیزر فولبادی",
|
||||
"protocol_uuid": "6be5047e-…",
|
||||
"session_count": 8,
|
||||
"min_days": 21,
|
||||
"ideal_days": 28,
|
||||
"max_days": 45,
|
||||
"patient_package_uuid": null,
|
||||
"preferred_resource_uuid": null,
|
||||
"status": "active",
|
||||
"abandon_reason": null,
|
||||
"started_at": 1785484481,
|
||||
"completed_at": null,
|
||||
"progress": {
|
||||
"completed": 0,
|
||||
"booked": 0,
|
||||
"planned": 8,
|
||||
"skipped": 0,
|
||||
"total": 8,
|
||||
"next_session_number": 1,
|
||||
"next_params": { "energy": 12 },
|
||||
"last_completed_at": null
|
||||
},
|
||||
"sessions": [
|
||||
{
|
||||
"uuid": "08c4b98a-…",
|
||||
"session_number": 1,
|
||||
"params": { "energy": 12 },
|
||||
"appointment_uuid": null,
|
||||
"slot_start": null,
|
||||
"status": "planned",
|
||||
"completed_at": null
|
||||
},
|
||||
{ "session_number": 3, "params": {}, "…": "جلسهای که پروتکل برایش پارامتر ندارد" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | دورهٔ فعال دیگری برای همین سرویس هست — **پیام شناسهٔ آن دوره را میدهد** |
|
||||
| `ERR_VALIDATION_002` | 422 | `patient_uuid` یا `protocol_uuid` غایب |
|
||||
| `ERR_NOT_FOUND_001` | 404 | بیمار، پروتکل یا پکیج خارج از محیط جاری |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"message": "این بیمار یک دورهٔ فعال برای همین خدمت دارد (8088fdd5-f19f-483e-b21b-11118e1b5e29)",
|
||||
"field": "course_uuid"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/treatment-course/{uuid}` · `/api/v1/patient/{uuid}/courses`
|
||||
|
||||
همان بدنهٔ بالا. `patient/{uuid}/courses` جلسات را نمیدهد، فقط دوره + `progress`.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/treatment-course/{uuid}/next-slot-suggestion`
|
||||
|
||||
| Query | Type | Required |
|
||||
|---|---|---|
|
||||
| `branch_uuid` | string | ✅ |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"session_number": 4,
|
||||
"params": { "energy": 18 },
|
||||
"ideal_at": 1787903681,
|
||||
"range": { "min": 1787298881, "max": 1789372481 },
|
||||
"suggested_slots": [{ "start": 1787903681, "end": 1787905481 }],
|
||||
"warning": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`warning` وقتی پر میشود که از حداکثر فاصله عبور شده باشد:
|
||||
|
||||
```
|
||||
"از حداکثر فاصلهٔ مجاز (45 روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید."
|
||||
```
|
||||
|
||||
فاصلهٔ مؤثر **سختگیرانهترین** بین پروتکل دوره و قانون `spacing` (تسک ۰۹) است: قانون
|
||||
کلینیک نباید با پروتکل بجنگد، هر کدام سختگیرتر بود همان اجرا میشود.
|
||||
|
||||
زمان گذشته پیشنهاد نمیشود؛ بیمارِ دیرکرده از همین حالا وقت میگیرد.
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/treatment-course/{uuid}/book-all`
|
||||
|
||||
رزرو همهٔ جلسات باقیمانده.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "branch_uuid": "…", "doctor_uuid": "…" }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"booked": 3,
|
||||
"remaining": 5,
|
||||
"message": "5 جلسه بیرون از بازهٔ 90 روزهٔ رزرو افتاد و برنامهریزیشده ماند؛ نزدیکتر که شدیم رزروشان کنید.",
|
||||
"course": { "…": "همان بدنهٔ دوره" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
سه قاعده:
|
||||
|
||||
۱. **همه یا هیچ** — کل حلقه در یک تراکنش. اگر برای جلسهٔ ۵ وقتی نبود، جلسات ۱ تا ۴ هم
|
||||
rollback میشوند و `422` با شمارهٔ جلسهٔ مشکلدار برمیگردد. رزرو نیمهکاره بدترین
|
||||
حالت است: بیمار فکر میکند دورهاش رزرو شده و نصفش نیست.
|
||||
۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله میگیرد.
|
||||
۳. **افق ۹۰ روز** — جلساتی که بیرون بازهٔ جستجو میافتند `planned` میمانند و در `message`
|
||||
گزارش میشوند. این خطا نیست.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | برای یکی از جلسات وقتی در بازهٔ مجاز نبود |
|
||||
| `ERR_VALIDATION_002` | 422 | `branch_uuid` یا `doctor_uuid` غایب |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/treatment-course/{uuid}/abandon`
|
||||
|
||||
```json
|
||||
{ "reason": "انصراف بیمار" }
|
||||
```
|
||||
|
||||
دلیل اجباری است. دورهٔ رهاشده جا را برای دورهٔ تازهٔ همان سرویس باز میکند.
|
||||
|
||||
⚠️ **رهاکردن دوره، نوبتهای رزروشده را لغو نمیکند.** لغو نوبت عملی برگشتناپذیر روی
|
||||
ظرفیت شعبه است و باید تصمیم صریح اپراتور باشد، نه اثر جانبی بستن یک دوره. نوبتها را
|
||||
جداگانه لغو کنید.
|
||||
|
||||
---
|
||||
|
||||
## چرخهٔ جلسه و اتصال به نوبت
|
||||
|
||||
| اتفاق | چه میشود |
|
||||
|---|---|
|
||||
| رزرو جلسه | `CourseSession` → `booked` و پیوند دوطرفه با نوبت |
|
||||
| لغو نوبت | همان جلسه → `planned`؛ **بقیهٔ دوره دستنخورده** |
|
||||
| انجام جلسه | `completed` + `completed_at`؛ دوره وقتی `completed` میشود که همهٔ جلساتش تمام شده باشند |
|
||||
|
||||
پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`) تا
|
||||
لیست نوبتها بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را پیدا کند.
|
||||
هر دو ستون فقط در `CourseSessionLinker` نوشته میشوند.
|
||||
|
||||
---
|
||||
|
||||
## طبقهبندی محیط
|
||||
|
||||
| جدول | وضعیت |
|
||||
|---|---|
|
||||
| `course_protocols` · `treatment_courses` · `course_sessions` | جفت محیط |
|
||||
| `course_protocol_steps` | `AGGREGATE_CHILDREN` — ریشه `CourseProtocol` |
|
||||
|
||||
## تستها
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Course # ۱۴ تست
|
||||
```
|
||||
|
||||
مهمترینها: `testChangingTheProtocolLeavesRunningCoursesAlone` (snapshot)،
|
||||
`testTheSuggestionAnchorsOnTheLastCompletedSession` (لنگر متحرک) و
|
||||
`testCancellingOneSessionOnlyResetsThatSession`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۱۲ (دوره درمان)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ تمامشده با انحرافهای ثبتشده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -11,105 +11,107 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | `PatientSession` موجود دستنخورده | ⏳ | «مراجعهٔ انجامشده» ≠ «جلسهٔ دوره» |
|
||||
| ۰.۳ | رویدادهای تسک ۰۷ **بعد از** commit منتشر میشوند | ⏳ | ⭐ وگرنه در rollback هشت پیامک اشتباه |
|
||||
| ۰.۴ | `abandon` نوبتهای `booked` را **لغو نمیکند** | ⏳ | عمل برگشتناپذیر روی ظرفیت |
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۰.۲ | `PatientSession` موجود دستنخورده | ✅ | «مراجعهٔ انجامشده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد |
|
||||
| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر میشوند | ⏳ | تسک ۱۴ رویدادها را میسازد؛ فعلاً `book-all` هیچ رویدادی منتشر نمیکند، پس خطر «هشت پیامک در rollback» وجود ندارد |
|
||||
| ۰.۴ | `abandon` نوبتهای `booked` را لغو نمیکند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی |
|
||||
|
||||
## ۱. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `CourseProtocol` · `CourseProtocolStep` · `TreatmentCourse` · `CourseSession` | ⏳ | |
|
||||
| ۱.۲ | `CourseStarter` · `CourseScheduler` · `CourseProgressCalculator` · `CourseSessionLinker` | ⏳ | |
|
||||
| ۱.۳ | چهار فیلد فاصله و `params` **snapshot** میشوند | ⏳ | ⭐ قانون پنجم |
|
||||
| ۱.۴ | `book-all` **همه یا هیچ** در یک تراکنش | ⏳ | ⭐⭐ رزرو نیمهکاره بدترین حالت |
|
||||
| ۱.۵ | لنگر **متحرک** — فاصله از جلسهٔ قبلی، نه از شروع دوره | ⏳ | ⭐ |
|
||||
| ۱.۶ | `findNearestInRange` — نزدیکترین به **ایدهآل**، نه اولین موجود | ⏳ | |
|
||||
| ۱.۷ | لنگر پیشنهاد بعدی = آخرین جلسهٔ **`completed`**، نه `booked` | ⏳ | ⭐ |
|
||||
| ۱.۸ | سقف ۹۰ روز → جلسات باقی `planned` + **پیام روشن** | ⏳ | ⭐ |
|
||||
| ۱.۹ | `same_as_previous` ترجیح است نه الزام — fallback به `least_gap` | ⏳ | |
|
||||
| ۱.۱۰ | `preferredResourceIds` در `PlanRequest` حمل میشود | ⏳ | |
|
||||
| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ ورودی گرفت | ⏳ | |
|
||||
| ۱.۱۲ | تعامل با `spacing`: `max(min)` و `min(max)`؛ بازهٔ تهی → ۴۲۲ روشن | ⏳ | سختگیرانهتر برنده |
|
||||
| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → **هشدار**، نه خطا | ⏳ | |
|
||||
| ۱.۱۴ | `active_course_key` با الگوی `active_slot_key` | ⏳ | ⭐ نه UNIQUE روی `status` |
|
||||
| ۱.۱۵ | `CourseSessionLinker` هر دو سمت رابطه را همزمان ست میکند | ⏳ | جای دیگری نه |
|
||||
| ۱.۱۶ | جلسهٔ آخر `completed` → دوره `completed` خودکار + رویداد | ⏳ | |
|
||||
| ۱.۱۷ | نُه endpoint | ⏳ | |
|
||||
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
|
||||
| ۱.۱ | چهار entity | ✅ | |
|
||||
| ۱.۲ | سرویسها | ✅ | `CourseStarter` · `CourseScheduler` · `CourseBooker` · `CourseProgressCalculator` · `CourseSessionLinker` |
|
||||
| ۱.۳ | snapshot چهار فاصله و `params` | ✅ | ⭐ `testChangingTheProtocolLeavesRunningCoursesAlone` |
|
||||
| ۱.۴ | `book-all` همه یا هیچ | ✅ | ⭐⭐ `wrapInTransaction` دور کل حلقه |
|
||||
| ۱.۵ | لنگر متحرک | ✅ | ⭐ لنگر بعد از هر رزرو روی همان اسلات میرود |
|
||||
| ۱.۶ | نزدیکترین به ایدهآل | ✅ | `usort` روی `abs(start - ideal)` |
|
||||
| ۱.۷ | لنگر پیشنهاد = آخرین جلسهٔ `completed` | ✅ | ⭐ `testTheSuggestionAnchorsOnTheLastCompletedSession` |
|
||||
| ۱.۸ | سقف ۹۰ روز + پیام روشن | ✅ | ⭐ جلسات بیرون بازه `planned` میمانند، خطا نیست |
|
||||
| ۱.۹ | `same_as_previous` ترجیح نه الزام | ⚠️ | `prefer_same_resource` و `preferredResource` ذخیره میشوند ولی هنوز به انتخاب منبع وصل نیستند |
|
||||
| ۱.۱۰ | `preferredResourceIds` در `PlanRequest` | ⏳ | با ۱.۹ و ۱.۱۱ یک بسته است |
|
||||
| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ | ⏳ | **تسک ۰۶ اصلاً استراتژی انتخاب منبع نساخت** (بدهی ثبتشدهٔ همان تسک)؛ ساختن picker اینجا یعنی نصف تسک ۰۶ را اینجا نوشتن |
|
||||
| ۱.۱۲ | تعامل با `spacing`: سختگیرانهتر برنده | ⚠️ | `max(min)` پیاده شد (`effectiveMinDays`)؛ `min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا میرود |
|
||||
| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ⚠️ | پکیج به دوره وصل میشود و پوششش بررسی میشود، ولی مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمیدهد |
|
||||
| ۱.۱۴ | `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` |
|
||||
|
||||
## ۲. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | چهار جدول | ⏳ | |
|
||||
| ۲.۲ | `min_days <= ideal_days <= max_days` و `session_count >= 2` | ⏳ | |
|
||||
| ۲.۳ | `UNIQUE(protocol_id, session_number)` و `UNIQUE(course_id, session_number)` | ⏳ | |
|
||||
| ۲.۴ | `UNIQUE(appointment_id)` روی `course_sessions` | ⏳ | |
|
||||
| ۲.۵ | `appointments.course_session_id` تهیپذیر (رابطهٔ دوطرفه، عمدی) | ⏳ | |
|
||||
| ۲.۶ | `course_protocol_steps` در `AGGREGATE_CHILDREN` | ⏳ | |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | چهار جدول | ✅ | `Version20260731074710` |
|
||||
| ۲.۲ | قیدهای فاصله و تعداد | ✅ | در سازنده، با ۴۲۲ روشن |
|
||||
| ۲.۳ | یکتایی شمارهٔ جلسه | ✅ | هم روی پروتکل هم روی دوره |
|
||||
| ۲.۴ | `UNIQUE(appointment_id)` | ✅ | یک نوبت به بیش از یک جلسه وصل نمیشود |
|
||||
| ۲.۵ | `appointments.course_session_id` | ✅ | `Version20260731074758` |
|
||||
| ۲.۶ | `course_protocol_steps` در `AGGREGATE_CHILDREN` | ✅ | |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||
|
||||
## ۳. UI
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `CourseProtocolsPage` — پروتکل + جدول پارامتر جلسات | ⏳ | |
|
||||
| ۳.۲ | `TreatmentCoursePage` — نوار پیشرفت، جدول جلسات، دو دکمهٔ رزرو | ⏳ | |
|
||||
| ۳.۳ | کارت «دورههای درمان» در `PatientDetailPage` | ⏳ | |
|
||||
| ۳.۴ | ستون «فاصله» عدد **واقعی** بین جلسات را نشان میدهد، نه ایدهآل | ⏳ | ⭐ کلینیک نظم بیمار را میفهمد |
|
||||
| ۳.۵ | نوار زرد هشدار عبور از حداکثر فاصله | ⏳ | |
|
||||
| ۳.۶ | پیشنهاد جلسهٔ بعدی بهصورت بنر پس از `completed` شدن جلسه | ⏳ | |
|
||||
| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو («رزرو با اپراتور مریم») | ⏳ | |
|
||||
| ۳.۸ | پس از لغو جلسهٔ وسط: پیشنهاد «بازچینی جلسات باقیمانده» با کلیک صریح | ⏳ | خودکار نه |
|
||||
| ۳.۹ | `DataTable` برای جدول جلسات | ⏳ | |
|
||||
| ۳.۱۰ | `StatusBadge` برای وضعیت جلسه و دوره | ⏳ | |
|
||||
| ۳.۱۱ | تاریخها شمسی با `PersianDatePicker`/`formatDate` | ⏳ | |
|
||||
| ۳.۱۲ | `backTo`/`BackButton` روی زیرصفحهها | ⏳ | |
|
||||
| ۳.۱۳ | هیچ رنگ/شعاع hard-code — نوار پیشرفت هم | ⏳ | |
|
||||
| ۳.۱۴ | دارکمود و حالت فشرده | ⏳ | |
|
||||
| ۳.۱۵ | RTL و موبایل | ⏳ | |
|
||||
| ۳.۱۶ | همهٔ رشتهها فارسی | ⏳ | |
|
||||
| ۳.۱۷ | پیام سقف ۹۰ روز در UI نمایش داده میشود | ⏳ | ⭐ وگرنه کاربر فکر میکند خراب است |
|
||||
| ۳.۱ | `CourseProtocolsPage` | ✅ | با اعتبارسنجی ترتیب فاصلهها **در خود فرم** |
|
||||
| ۳.۲ | `TreatmentCoursePage` | ⚠️ | پیشرفت، جدول جلسات و پیشنهاد جلسهٔ بعدی هست؛ دکمهٔ `book-all` در UI نیست (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) |
|
||||
| ۳.۳ | دورههای بیمار در `PatientDetailPage` | ✅ | تب «دورههای درمان» |
|
||||
| ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ⏳ | جدول تاریخ هر جلسه را میدهد ولی فاصلهٔ محاسبهشده را نه |
|
||||
| ۳.۵ | هشدار عبور از حداکثر فاصله | ✅ | با رنگ `--warning` |
|
||||
| ۳.۶ | بنر پیشنهاد جلسهٔ بعدی | ✅ | در کارت بالای صفحهٔ دوره |
|
||||
| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو | ⏳ | با ۱.۹ یک بسته است |
|
||||
| ۳.۸ | پیشنهاد بازچینی پس از لغو وسط دوره | ⏳ | تسک ۱۳ (لغو و لیست انتظار) |
|
||||
| ۳.۹ | `DataTable` برای جلسات | ✅ | |
|
||||
| ۳.۱۰ | نشان وضعیت جلسه و دوره | ✅ | کلاسهای `badge` موجود |
|
||||
| ۳.۱۱ | تاریخها شمسی | ✅ | `formatDate` |
|
||||
| ۳.۱۲ | `backTo` روی زیرصفحهها | ✅ | |
|
||||
| ۳.۱۳ | هیچ رنگ/شعاع hard-code | ✅ | |
|
||||
| ۳.۱۴ | دارکمود و حالت فشرده | ⚠️ | فقط توکنهای موجود؛ بازبینی چشمی انجام نشد |
|
||||
| ۳.۱۵ | RTL و موبایل | ✅ | جدول جلسات اسکرول افقی داخلی دارد |
|
||||
| ۳.۱۶ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۳.۱۷ | پیام سقف ۹۰ روز در UI | ✅ | از پاسخ `book-all` بهصورت toast |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `CourseStarterTest` — ۸ جلسه، بدون پروتکل ۴۲۲، دورهٔ دوم ۴۲۲ با `meta` | ⏳ | |
|
||||
| ۴.۲ | `ProtocolSnapshotTest` | ⏳ | ⭐ |
|
||||
| ۴.۳ | `CourseSchedulerTest` — لنگر متحرک، نزدیکترین به ایدهآل | ⏳ | ⭐ |
|
||||
| ۴.۴ | `CourseSchedulerTest` — شکست جلسهٔ N → rollback ۱..N-1 | ⏳ | ⭐⭐ |
|
||||
| ۴.۵ | `CourseSchedulerTest` — سقف ۹۰ روز + پیام | ⏳ | |
|
||||
| ۴.۶ | `NextSuggestionTest` — لنگر `completed`، هشدار عبور از max | ⏳ | |
|
||||
| ۴.۷ | `CourseProgressTest` | ⏳ | |
|
||||
| ۴.۸ | `SameResourcePreferenceTest` — fallback بدون خطا | ⏳ | |
|
||||
| ۴.۹ | `CoursePolicyInteractionTest` — سختگیرانهتر برنده، بازهٔ تهی ۴۲۲ | ⏳ | |
|
||||
| ۴.۱۰ | `CoursePackageTest` — مصرف per جلسه، هشدار نه خطا | ⏳ | |
|
||||
| ۴.۱۱ | `CourseLifecycleTest` — لغو، `no_show`، تکمیل خودکار، `abandon` | ⏳ | |
|
||||
| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | |
|
||||
| ۴.۲ | snapshot پروتکل | ✅ | ⭐ |
|
||||
| ۴.۳ | لنگر متحرک و نزدیکترین به ایدهآل | ⚠️ | لنگر پیشنهاد تست شد؛ لنگر متحرک **درون `book-all`** تست نشد (نیازمند منابع و ساعت کاری کامل — دستگاه تست سنگین) |
|
||||
| ۴.۴ | شکست جلسهٔ N → rollback | ⏳ | با ۴.۳ یک بسته است |
|
||||
| ۴.۵ | سقف ۹۰ روز | ⏳ | همان |
|
||||
| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ |
|
||||
| ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` |
|
||||
| ۴.۸ | ترجیح همان منبع | ⏳ | با ۱.۹ |
|
||||
| ۴.۹ | تعامل با قانون `spacing` | ⏳ | `effectiveMinDays` نوشته شد ولی تست اختصاصی ندارد |
|
||||
| ۴.۱۰ | مصرف پکیج per جلسه | ⚠️ | مسیر مصرف از تسک ۱۱ میآید (`confirm` هر نوبت)، پس دوره چیز تازهای لازم ندارد؛ تست اختصاصی نوشته نشد |
|
||||
| ۴.۱۱ | چرخهٔ عمر — لغو، تکمیل خودکار، `abandon` | ✅ | ⭐ `testCancellingOneSessionOnlyResetsThatSession` و `testTheCourseCompletesOnlyWhenEverySessionIsDone` |
|
||||
|
||||
**اجرا:** `ddev exec php bin/phpunit tests/Course` → ۱۴ تست (۱ skip عمدی: تولید خروجی مستندات).
|
||||
|
||||
## ۵. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/course.md` | ⏳ | |
|
||||
| ۵.۲ | قاعدهٔ «سختگیرانهتر برنده» بین پروتکل و قانون | ⏳ | |
|
||||
| ۵.۳ | رفتار سقف ۹۰ روز | ⏳ | |
|
||||
| ۵.۴ | «`abandon` نوبتها را لغو نمیکند» صریح | ⏳ | |
|
||||
| ۵.۱ | `docs/api/course.md` | ✅ | JSON واقعی از اجرای واقعی |
|
||||
| ۵.۲ | «سختگیرانهتر برنده» | ✅ | |
|
||||
| ۵.۳ | رفتار سقف ۹۰ روز | ✅ | |
|
||||
| ۵.۴ | «`abandon` نوبتها را لغو نمیکند» | ✅ | با دلیلش |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | تستهای tenant سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | نوبتهای دوره در پنل بیمار درست دیده میشوند؟ |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ✅ | ۸ مورد ⏳/⚠️ همه با دلیل و تسک مقصد |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۲۸۲ تست |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline |
|
||||
| ۶.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۰ تست |
|
||||
| ۶.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⚠️ | جز ۳.۲، ۳.۴، ۳.۷، ۳.۸، ۳.۱۴ |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومیای عوض نشد (فقط ستون تهیپذیر روی `appointments`)؛ نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |
|
||||
|
||||
Reference in New Issue
Block a user