Files
clinicpro/docs/new_feture/taskes/task-12-treatment-course/architecture.md
T
hamed 021d0eb6b2 feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
2026-07-30 11:43:58 +03:30

8.6 KiB
Raw Blame History

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

ساختار فایل

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

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

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 — رزرو یکجا

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 — نزدیک‌ترین به ایده‌آل

$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 — اتصال به تسک ۰۶

// 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 و پروتکل دوره هر دو فاصله را محدود می‌کنند. قانون برنده است اگر سخت‌گیرانه‌تر باشد:

$effectiveMin = max($course->getMinDays(), $policyMinDays ?? 0);

دلیل: پروتکل پیشنهاد بالینی است، قانون سیاست کلینیک. سیاست کلینیک نمی‌تواند شل‌تر شود. این را در docs/api/course.md بنویس.

پنل ادمین

  • CourseProtocolsPage.tsx — پروتکل per سرویس + جدول پارامتر جلسات
  • TreatmentCoursePage.tsx — نوار پیشرفت («۳ از ۸»)، جدول جلسات با وضعیت و تاریخ، دکمهٔ «رزرو جلسهٔ بعدی» و «رزرو همهٔ جلسات»
  • کارت دوره‌ها در PatientDetailPage.tsx
  • نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از همان می‌فهمد بیمار منظم است یا نه