- 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.
187 lines
8.9 KiB
Markdown
187 lines
8.9 KiB
Markdown
# معماری — تسک ۰۶
|
|
|
|
## ساختار فایل
|
|
|
|
```
|
|
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<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` — تطبیق
|
|
|
|
مسئلهٔ واقعی: هر بخش چند نیازمندی دارد، هر نیازمندی چند کاندید، و **منبع مشترک بین
|
|
بخشها باید یکی باشد**.
|
|
|
|
```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` با تأیید صریح.
|