Files
clinicpro/docs/new_feture/taskes/task-06-availability-engine/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.9 KiB

معماری — تسک ۰۶

ساختار فایل

src/Appointment/Availability/
├── AvailabilityEngine.php          # ارکستراتور
├── CandidateGenerator.php          # نقطه‌های شروع ممکن + هرس
├── ResourceAllocator.php           # تطبیق نیازمندی‌ها به منابع آزاد
├── OccupancyIndex.php              # ایندکس درون‌حافظه‌ای اشغال‌ها
├── Strategy/
│   ├── ResourcePickerInterface.php
│   ├── LeastGapPicker.php          # پیش‌فرض
│   ├── BalancedPicker.php
│   ├── PreserveSpecialistsPicker.php
│   └── SameAsPreviousPicker.php
├── Cache/DailyWindowCache.php
├── Dto/{AvailabilitySlot, ResourceAssignment, AvailabilityRequest}.php
└── Controller/AvailabilityController.php

جریان اصلی

public function search(AvailabilityRequest $req): array
{
    // ۱. برنامه یک بار ساخته می‌شود، نه per روز  (تسک ۰۵)
    $plan = $this->planBuilder->build($req->toPlanRequest());

    // ۲. منابع کاندید هر نیازمندی — یک بار برای کل بازه
    $candidates = $plan->allCandidateResourceIds();

    // ۳. سه واکشی انبوه برای کل بازه (نه per روز، نه per منبع)
    $windows    = $this->availability->rawWindowsBulk($candidates, $req->from, $req->to);
    $occupancy  = $this->occupancyRepo->findForResources($candidates, $req->from, $req->to);
    $index      = OccupancyIndex::build($occupancy, $windows);

    // ۴. نقطه‌های شروع کاندید + هرس
    $starts = $this->candidates->generate($plan, $windows, $req);

    // ۵. برای هر نقطه: تخصیص منبع
    $result = [];
    foreach ($starts as $start) {
        $assignment = $this->allocator->tryAllocate($plan, $start, $index, $req->strategy);
        if ($assignment !== null) {
            $result[] = new AvailabilitySlot($start, $plan->totalMinutes, $assignment);
            if (count($result) >= $req->limit) break;
        }
    }

    // ۶. قلاب تسک ۰۹: قوانین فاصلهٔ زمانی
    return $this->policies->filterSlots($result, $req);
}

سه کوئری برای کل بازه. هیچ کوئری‌ای داخل حلقه. این تنها راه رسیدن به هدف نیم ثانیه است.

OccupancyIndex

ساختار درون‌حافظه‌ای که «آیا منبع R در بازهٔ [s, e) جا دارد؟» را بدون کوئری جواب می‌دهد:

final class OccupancyIndex
{
    /** @var array<int, array<array{start:int,end:int,units:int}>> resourceId → بازه‌های اشغال مرتب */
    private array $busy;

    /** @var array<int, array<array{start:int,end:int}>> resourceId → پنجره‌های آزاد */
    private array $windows;

    /** @var array<int,int> resourceId → capacity */
    private array $capacity;

    public function hasRoom(int $resourceId, int $start, int $end, int $units): bool
    {
        // ۱. باید کاملاً داخل یکی از پنجره‌های آزاد باشد
        // ۲. جمع units اشغال‌های متداخل + units درخواستی <= capacity
    }
}

بازه‌های busy مرتب نگه داشته می‌شوند تا بررسی تداخل با جستجوی دودویی روی نقطهٔ شروع انجام شود، نه پیمایش خطی — با ۵۰۰ نوبت × ده‌ها کاندید، تفاوتش دیده می‌شود.

CandidateGenerator — هرس زودهنگام

گام پیش‌فرض: ۱۵ دقیقه (قابل تنظیم per محیط: appointment_settings.slot_granularity)

برای هر روز از بازه:
    ۱. پنجره‌های آزاد «تنگ‌ترین منبع» را بگیر
       (منبعی که کمترین دقیقهٔ آزاد در آن روز دارد — معمولاً دستگاه)
    ۲. نقطه‌های شروع فقط داخل آن پنجره‌ها تولید شوند
    ۳. نقطه‌ای که [start, start+totalMinutes) از پنجره بیرون بزند → حذف
    ۴. نقطهٔ گذشته → حذف

گام ۱ مهم‌ترین هرس است: اگر دستگاه لیزر روزی ۴ ساعت آزاد است، تولید ۹۶ کاندید برای ۲۴ ساعت بی‌معنی است. با این هرس معمولاً ۸۰٪ کاندیدها قبل از هر محاسبه‌ای حذف می‌شوند.

ResourceAllocator — تطبیق

مسئلهٔ واقعی: هر بخش چند نیازمندی دارد، هر نیازمندی چند کاندید، و منبع مشترک بین بخش‌ها باید یکی باشد.

public function tryAllocate(AppointmentPlan $plan, int $start, OccupancyIndex $index, string $strategy): ?ResourceAssignment
{
    $chosen = [];   // requirementKey → resourceId

    foreach ($plan->segments as $segment) {
        foreach ($segment->requirements as $req) {
            $key = $req->groupKey();   // نقش + مهارت‌ها + قیدها → نیازمندی‌های هم‌شکل یک منبع می‌گیرند

            if (isset($chosen[$key])) {
                // منبع قبلاً انتخاب شده — فقط باید در این بازه هم آزاد باشد
                if (!$index->hasRoom($chosen[$key], )) return null;
                continue;
            }

            $free = array_filter($req->candidateIds, fn($id) => $index->hasRoom($id, ));
            if ($free === []) return null;

            $chosen[$key] = $this->pickers[$strategy]->pick($free, $req, $index, $plan);
        }
    }

    return new ResourceAssignment($chosen);
}

groupKey() — چرا لازم است

اپراتورِ بخش ۱ و اپراتورِ بخش ۳ باید یک نفر باشند (بیمار وسط کار اپراتور عوض نمی‌کند). groupKey نیازمندی‌های هم‌شکل را یکی می‌کند. اگر واقعاً دو نفر لازم است، نیازمندی باید count = 2 باشد یا مهارت/قید متفاوت داشته باشد.

⚠️ این ساده‌سازی است: در حالت کلی، تخصیص با backtracking کامل است. عمداً backtracking نمی‌کنیم — با سقف‌های تسک ۰۵ (۲۰ بخش، ۱۰ نیازمندی) حالت‌های شکست نادرند و هزینهٔ backtracking در مسیر داغ توجیه ندارد. اگر تخصیص حریصانه شکست خورد، آن نقطهٔ شروع رد می‌شود؛ بدترین حالت یعنی یک زمان ممکن نمایش داده نمی‌شود، نه یک رزرو اشتباه. این تصمیم را در docs/api/appointment-availability.md بنویس.

استراتژی‌های انتخاب منبع

استراتژی قاعده کاربرد
least_gap (پیش‌فرض) منبعی که کمترین شکاف بلااستفاده بسازد — نزدیک‌ترین اشغال قبلی/بعدی بیشترین بهره‌وری
balanced کم‌کارترین منبع آن روز رضایت پرسنل
preserve_specialists کمترین level کافی — متخصص برای کار ساده مصرف نشود کلینیک با اپراتور ماهر کم
same_as_previous همان منبع جلسات قبلی همان بیمار (تسک ۱۲) دوره‌های درمان

ResourcePickerInterface با تزریق آرایه‌ای (!tagged_iterator) — افزودن استراتژی پنجم نباید هیچ کلاس موجودی را تغییر دهد (OCP).

کش پنجره‌های روزانه

final class DailyWindowCache
{
    // کلید: resource:{id}:windows:{Y-m-d}
    // TTL: تا پایان همان روز
    // ابطال: هر تغییر در resource_calendars / resource_exceptions / holidays آن منبع
}

Redis از قبل در استک هست (symfony/redis-messenger). فقط پنجره‌های تقویمی کش می‌شوند، نه اشغال‌ها — اشغال هر ثانیه عوض می‌شود و کش‌کردنش یعنی نمایش وقتِ گرفته‌شده.

حالت resource روی برنامهٔ هفتگی

// WeeklySchedule
public const MODE_RESOURCE = 'resource';
public const DEFAULT_META = [
    ,
    'booking_mode'      => self::MODE_SLOT,
    'slot_granularity'  => 15,                 // جدید
    'picker_strategy'   => 'least_gap',        // جدید
];

booking_mode امروز پس از اولین ثبت قفل می‌شود (getStoredBookingMode()). این تسک یک استثنای کنترل‌شده اضافه می‌کند: ارتقا از slot/service به resource مجاز است (یک‌طرفه، بازگشت ممنوع)، مشروط بر اینکه هیچ نوبت فعال آینده‌ای وجود نداشته باشد. POST /api/v1/appointment-settings/upgrade-booking-mode با تأیید صریح.