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:
hamed
2026-07-31 11:33:07 +03:30
co-authored by Claude Opus 5
parent edf22e0552
commit fc504f4415
30 changed files with 3502 additions and 75 deletions
@@ -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 .` | | دو کامیت جدا |
| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |