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