# معماری — تسک ۱۲

## ساختار فایل

```
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`
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
  همان می‌فهمد بیمار منظم است یا نه
