# معماری — تسک ۰۶ ## ساختار فایل ``` 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 ``` ## جریان اصلی ```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) جا دارد؟» را بدون کوئری جواب می‌دهد: ```php final class OccupancyIndex { /** @var array> resourceId → بازه‌های اشغال مرتب */ private array $busy; /** @var array> resourceId → پنجره‌های آزاد */ private array $windows; /** @var array 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` — تطبیق مسئلهٔ واقعی: هر بخش چند نیازمندی دارد، هر نیازمندی چند کاندید، و **منبع مشترک بین بخش‌ها باید یکی باشد**. ```php 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). ## کش پنجره‌های روزانه ```php final class DailyWindowCache { // کلید: resource:{id}:windows:{Y-m-d} // TTL: تا پایان همان روز // ابطال: هر تغییر در resource_calendars / resource_exceptions / holidays آن منبع } ``` Redis از قبل در استک هست (`symfony/redis-messenger`). **فقط پنجره‌های تقویمی کش می‌شوند، نه اشغال‌ها** — اشغال هر ثانیه عوض می‌شود و کش‌کردنش یعنی نمایش وقتِ گرفته‌شده. ## حالت `resource` روی برنامهٔ هفتگی ```php // 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` با تأیید صریح.