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.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# معماری — تسک ۰۶
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
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` با تأیید صریح.
|
||||
Reference in New Issue
Block a user