feat(treatment): add treatment protocols, the multi-session course of a service

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:36:04 +03:30
co-authored by Claude Opus 5
parent 85985b04a0
commit e2e3e6b43b
21 changed files with 1107 additions and 1200 deletions
@@ -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`
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
همان می‌فهمد بیمار منظم است یا نه
@@ -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 .` | ✅ | دو کامیت جدا |
| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |
@@ -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` |
@@ -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` نوبت‌ها را لغو نمی‌کند.
@@ -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`
@@ -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 (تسک ۱۴)
کارت بیمار: «دورهٔ لیزر فول‌بادی تکمیل شد — ۸ جلسه در ۲۳۱ روز»
[شروع دورهٔ نگهدارنده]
```