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` با تأیید صریح.
|
||||
@@ -0,0 +1,95 @@
|
||||
# دیتابیس — تسک ۰۶
|
||||
|
||||
این تسک **جدول جدیدی نمیسازد** جز کش. مصرفکنندهٔ جدولهای تسک ۰۲/۰۳ و
|
||||
`resource_occupancy` تسک ۰۷ است.
|
||||
|
||||
⚠️ **وابستگی معکوس:** موتور جستجو به `resource_occupancy` نیاز دارد ولی آن جدول در تسک ۰۷
|
||||
ساخته میشود. راهحل: **مهاجرت جدول `resource_occupancy` در همین تسک انجام شود** و تسک ۰۷
|
||||
فقط منطق نوشتن در آن را اضافه کند. تعریف کامل جدول در
|
||||
[task-07/database.md](../task-07-hold-and-book/database.md) است؛ اینجا فقط ایندکسهای
|
||||
لازم برای خواندن ذکر میشوند.
|
||||
|
||||
## ایندکسهای حیاتی خواندن
|
||||
|
||||
```sql
|
||||
-- کوئری داغ: اشغالهای این منابع در این بازه
|
||||
KEY idx_occupancy_resource_range (resource_id, start_at, end_at, status)
|
||||
```
|
||||
|
||||
پرسوجو:
|
||||
|
||||
```sql
|
||||
SELECT resource_id, start_at, end_at, units
|
||||
FROM resource_occupancy
|
||||
WHERE resource_id IN (?, ?, …)
|
||||
AND start_at < :to AND end_at > :from
|
||||
AND status IN ('hold', 'booked')
|
||||
```
|
||||
|
||||
`resource_id` ستون اول است چون `IN` روی آن، محدودکنندهترین شرط است. اضافه کردن
|
||||
`entity_type` به ابتدای این ایندکس **اشتباه** است: اینجا فیلتر tenant از راه منابع
|
||||
(که خودشان محیط دارند) اعمال شده و ستون tenant-پیشرو فقط ایندکس را بیاثر میکند.
|
||||
یک ایندکس دوم tenant-پیشرو برای لیستهای پنل جدا تعریف میشود (تسک ۰۷).
|
||||
|
||||
## تنظیمات جدید روی `weekly_schedules.setting.meta`
|
||||
|
||||
بدون تغییر schema (ستون JSON موجود):
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"booking_mode": "resource",
|
||||
"slot_granularity": 15,
|
||||
"picker_strategy": "least_gap",
|
||||
"buffer_minutes": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`setMeta()` باید کلیدهای جدید را با اعتبارسنجی بپذیرد:
|
||||
- `slot_granularity` ∈ {5, 10, 15, 20, 30, 60}
|
||||
- `picker_strategy` ∈ کلیدهای ثبتشدهٔ `ResourcePickerInterface`
|
||||
|
||||
مقدار نامعتبر → مقدار فعلی حفظ میشود (همان الگوی موجود `setMeta`).
|
||||
|
||||
## کش
|
||||
|
||||
Redis، بدون جدول. کلیدها:
|
||||
|
||||
```
|
||||
cp:avail:res:{resourceId}:win:{Y-m-d} → JSON بازههای آزاد TTL تا پایان روز
|
||||
cp:avail:month:{branchId}:{serviceId}:{Y-m} → JSON بولین per روز TTL 300s
|
||||
```
|
||||
|
||||
ابطال:
|
||||
|
||||
| رویداد | کلیدهای باطل |
|
||||
|---|---|
|
||||
| تغییر `resource_calendars` | همهٔ `win` آن منبع |
|
||||
| ثبت/حذف `resource_exceptions` | `win` آن منبع در بازهٔ استثنا |
|
||||
| تغییر `branch_working_hours` | `win` همهٔ منابع آن شعبه |
|
||||
| تغییر `tenant_holiday_overrides` | `win` همهٔ منابع آن محیط در آن روز |
|
||||
| ثبت/لغو نوبت | فقط `month` — **`win` هرگز** (اشغال کش نمیشود) |
|
||||
|
||||
آخرین سطر مهمترین است: اگر کسی وسوسه شد اشغال را هم کش کند، نتیجهاش نمایش وقتِ
|
||||
گرفتهشده و شکست رزرو در مرحلهٔ آخر است.
|
||||
|
||||
## تست کارایی — داده مصنوعی
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:dev:seed-availability-benchmark --force
|
||||
```
|
||||
|
||||
میسازد:
|
||||
- ۱ شعبه · ۳ اتاق (ظرفیت ۱) · ۲ اپراتور · ۳ دستگاه در یک استخر
|
||||
- ۱ سرویس با ۴ بخش (سناریوی مستند)
|
||||
- ۵۰۰ نوبت پراکنده در ۳۰ روز آینده
|
||||
|
||||
`AvailabilityPerformanceTest` روی همین داده اجرا میشود و **دو** چیز را میسنجد:
|
||||
|
||||
```php
|
||||
self::assertLessThan(500, $elapsedMs, 'جستجوی ۳۰ روزه باید زیر نیم ثانیه باشد');
|
||||
self::assertLessThanOrEqual(5, $queryCount, 'تعداد کوئری نباید با تعداد روز رشد کند');
|
||||
```
|
||||
|
||||
شرط دوم مهمتر از اولی است: زمان روی ماشینهای مختلف فرق میکند، تعداد کوئری نه.
|
||||
@@ -0,0 +1,158 @@
|
||||
# نکات پیادهسازی — تسک ۰۶
|
||||
|
||||
## ۱. سه کوئری، بعد هیچ
|
||||
|
||||
قاعدهٔ غیرقابلمذاکره: **داخل حلقهٔ روز و حلقهٔ نقطهٔ شروع، هیچ I/O نیست.**
|
||||
|
||||
```php
|
||||
// ❌ مرگ کارایی
|
||||
foreach ($days as $day) {
|
||||
foreach ($starts as $start) {
|
||||
if ($this->occupancyRepo->isFree($resource, $start, $end)) { … } // ← N×M کوئری
|
||||
}
|
||||
}
|
||||
|
||||
// ✅
|
||||
$index = OccupancyIndex::build($this->occupancyRepo->findForResources($ids, $from, $to), $windows);
|
||||
foreach ($starts as $start) { $index->hasRoom($id, $start, $end, 1); }
|
||||
```
|
||||
|
||||
`AvailabilityPerformanceTest` تعداد کوئری را قفل میکند تا اولین refactor این را نشکند.
|
||||
|
||||
## ۲. `setup/cleanup` — بازهٔ اشغال، نه بازهٔ بخش
|
||||
|
||||
```php
|
||||
$occStart = $segmentStart - $resource->getSetupMinutes() * 60;
|
||||
$occEnd = $segmentEnd + $resource->getCleanupMinutes() * 60;
|
||||
$index->hasRoom($resourceId, $occStart, $occEnd, $units);
|
||||
```
|
||||
|
||||
نکتهٔ ظریف: `setup/cleanup` per **منبع** است، ولی منبع در لحظهٔ بررسی هنوز انتخاب نشده.
|
||||
پس دو گذر:
|
||||
|
||||
1. بررسی اولیه با **بیشینهٔ** `setup/cleanup` کاندیدها (محافظهکار)
|
||||
2. بعد از انتخاب منبع، بازهٔ دقیق همان منبع محاسبه و دوباره بررسی شود
|
||||
|
||||
گذر دوم ارزان است (یک منبع، یک بازه) و از رد شدن اشتباه کاندیدها جلوگیری میکند.
|
||||
|
||||
## ۳. `capacity` و `units`
|
||||
|
||||
```
|
||||
exclusive → units = capacity (منبع کامل)
|
||||
shared → units = 1
|
||||
passive → units = capacity (رزرو است، ولی پرچم passive برای گزارش)
|
||||
```
|
||||
|
||||
`hasRoom` جمع `units` اشغالهای متداخل را با `capacity` مقایسه میکند. با این مدل،
|
||||
اتاق تزریق سهتخته با یک ردیف کار میکند و شمارش خودکار است.
|
||||
|
||||
## ۴. منبع مشترک بین بخشها
|
||||
|
||||
مثال مستند: اپراتور در بخش ۱ (۰-۵) و بخش ۳ (۳۵-۵۵) لازم است، در بخش ۲ نه.
|
||||
|
||||
- **باید همان اپراتور باشد** → `groupKey` در `ResourceAllocator`
|
||||
- **در بخش ۲ نباید اشغال بماند** → دو ردیف اشغال جدا، نه یکی از ۰ تا ۵۵
|
||||
|
||||
اگر ردیف را یکی کنی، کل ارزش این پروژه از بین میرود: همان ۳۰ دقیقهای که میخواستیم
|
||||
آزاد کنیم دوباره قفل میشود. تست پذیرش «آزادسازی ظرفیت» دقیقاً همین را میسنجد.
|
||||
|
||||
## ۵. مسیر قدیمی دستنخورده
|
||||
|
||||
`SlotCalculatorService` **هیچ تغییری نمیکند**. `AvailabilityEngine` یک کلاس جدید کنارش است.
|
||||
انتخاب بین این دو فقط در کنترلر و بر اساس `booking_mode`:
|
||||
|
||||
```php
|
||||
$mode = $schedule?->getMeta()['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
||||
|
||||
return match ($mode) {
|
||||
WeeklySchedule::MODE_RESOURCE => $this->availabilityEngine->search($req),
|
||||
WeeklySchedule::MODE_SERVICE => $this->slotCalculator->getServiceStartTimes(…), // بدون تغییر
|
||||
default => $this->slotCalculator->getAvailableSlots(…), // بدون تغییر
|
||||
};
|
||||
```
|
||||
|
||||
هر endpoint فقط حالت خودش را میپذیرد و بقیه را با `ERR_WRONG_BOOKING_MODE` رد میکند —
|
||||
نه fallback خاموش. fallback خاموش یعنی کلینیکی که فکر میکند حالت جدید دارد، بیصدا
|
||||
روی حالت قدیم کار میکند و هیچکس نمیفهمد چرا ظرفیتش باز نشد.
|
||||
|
||||
## ۶. ارتقای حالت — یکطرفه و با شرط
|
||||
|
||||
```
|
||||
POST /api/v1/appointment-settings/upgrade-booking-mode
|
||||
{ "schedule_uuid": "…", "confirm": true }
|
||||
|
||||
شرایط:
|
||||
- حالت فعلی slot یا service باشد
|
||||
- هیچ نوبت pending/confirmed آیندهای وجود نداشته باشد
|
||||
- حداقل یک منبع فعال در شعبه باشد
|
||||
- سرویسهای bookable حداقل یک SegmentTemplate یا duration معتبر داشته باشند
|
||||
|
||||
بازگشت به حالت قبلی: ممنوع (پاسخ 422)
|
||||
```
|
||||
|
||||
دلیل ممنوعیت بازگشت: نوبتهای ثبتشده در حالت `resource` بخش و اشغال چندمنبعی دارند و
|
||||
مدل قدیمی نمیتواند نمایششان دهد.
|
||||
|
||||
## ۷. سقفها و پیشفرضها
|
||||
|
||||
| پارامتر | پیشفرض | سقف |
|
||||
|---|---|---|
|
||||
| بازهٔ جستجو | ۳۰ روز | ۹۰ روز (مستند بند ۱۰) |
|
||||
| `limit` نتایج | ۵۰ | ۲۰۰ |
|
||||
| گام کاندید | ۱۵ دقیقه | حداقل ۵ |
|
||||
| منابع کاندید per نیازمندی | — | ۵۰ (بیشتر → `422` با پیشنهاد استفاده از استخر) |
|
||||
|
||||
## ۸. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| هیچ نتیجهای در بازه | `data: []` + `reason` (`no_resource`, `fully_booked`, `no_calendar`) — نه ۴۰۴ |
|
||||
| نقطهٔ شروع دقیقاً روی لبهٔ پنجرهٔ آزاد | معتبر — بازهها نیمباز `[s, e)` |
|
||||
| نوبتی که تازه لغو شده | با کش پنجرهای تداخل ندارد چون اشغال کش نمیشود |
|
||||
| `hold` منقضیشده در `resource_occupancy` | در کوئری `WHERE status='hold' AND expires_at > :now` رد شود |
|
||||
| برنامهٔ ۶۰ دقیقهای و پنجرهٔ آزاد ۵۹ دقیقه | هیچ کاندیدی — هرس گام ۳ |
|
||||
| منبعِ استخری که وسط بازه غیرفعال شده | `findEligible` فقط `active=true` میدهد؛ اشغالهای قبلیاش میمانند |
|
||||
| دو نیازمندی همشکل با `count=1` در یک بخش | `groupKey` یکسان → همان منبع دوبار انتخاب میشود ← **باگ**. `count=2` بنویس یا `groupKey` را با اندیس نیازمندی درون همان بخش متمایز کن |
|
||||
| تغییر ساعت رسمی (تغییر ساعت تابستانی) | ایران از ۱۴۰۱ ندارد؛ ولی محاسبات با timestamp انجام شود نه ساعت محلی |
|
||||
|
||||
سطر ماقبل آخر یک تلهٔ واقعی است: `groupKey` باید بین **بخشها** یکی باشد ولی درون یک
|
||||
بخش، دو نیازمندی مجزا دو منبع بگیرند. کلید = `(role, skills, constraints, indexInSegment)`
|
||||
و تطبیق بینبخشی روی سه جزء اول.
|
||||
|
||||
## ۹. تست
|
||||
|
||||
```
|
||||
tests/Appointment/Availability/OccupancyIndexTest.php ← واحد، بدون DB
|
||||
- capacity=3 با ۲ اشغال → جا دارد؛ با ۳ → ندارد
|
||||
- بازهٔ مماس (end == start) → تداخل نیست
|
||||
- shared vs exclusive
|
||||
tests/Appointment/Availability/CandidateGeneratorTest.php
|
||||
- هرس با تنگترین منبع
|
||||
- نقطهٔ گذشته حذف
|
||||
- برنامهای که در پنجره جا نمیشود → هیچ کاندید
|
||||
tests/Appointment/Availability/ResourceAllocatorTest.php
|
||||
- منبع مشترک بین بخش ۱ و ۳ → یک نفر
|
||||
- دو نیازمندی همشکل در یک بخش → دو منبع
|
||||
- تخصیص ناموفق → null، نه استثنا
|
||||
tests/Appointment/Availability/CapacityReleaseTest.php ← ⭐ تست پذیرش اصلی
|
||||
- نوبت الف ۱۰:۰۰-۱۱:۰۰، اپراتور فقط ۱۰:۰۰-۱۰:۰۵ و ۱۰:۳۵-۱۱:۰۰
|
||||
- جستجوی بیمار ب → زمانی در ۱۰:۰۵-۱۰:۳۵ پیدا شود
|
||||
tests/Appointment/Availability/StrategyTest.php
|
||||
- least_gap کمترین شکاف را میسازد
|
||||
- balanced کمکارترین را میدهد
|
||||
- preserve_specialists کمترین level کافی را میدهد
|
||||
tests/Appointment/Availability/BookingModeGuardTest.php
|
||||
- حالت slot روی endpoint جدید → 422 ERR_WRONG_BOOKING_MODE
|
||||
- endpoint قدیمی در حالت resource → 422
|
||||
- ارتقا با نوبت فعال آینده → 422
|
||||
tests/Appointment/AvailabilityPerformanceTest.php
|
||||
- < 500ms و <= 5 کوئری
|
||||
tests/Appointment/LegacyBookingUnchangedTest.php
|
||||
- همهٔ تستهای موجود appointment-slots و appointment-service-slots سبز بمانند
|
||||
```
|
||||
|
||||
## ۱۰. مستندات
|
||||
|
||||
`docs/api/appointment-availability.md` بساز — شامل جدول استراتژیها، توضیح تخصیص حریصانه
|
||||
و محدودیتش، و ماتریس «کدام endpoint در کدام حالت کار میکند».
|
||||
`docs/api/appointment.md` را با بخش «حالتهای نوبتدهی» بهروز کن.
|
||||
@@ -0,0 +1,78 @@
|
||||
# تسک ۰۶ — موتور جستجوی وقت چندمنبعی
|
||||
|
||||
**فاز:** ۱ (هسته) · **وابستگی:** ۰۳، ۰۵ · **زمان:** ۲۰-۲۴ ساعت
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
مستند بند ۱۰: برنامهٔ نوبت (تسک ۰۵) را روی تقویم منابع (تسک ۰۳) بلغزان و بگو چه
|
||||
ساعتهایی واقعاً ممکناند — با پیشنهاد اینکه کدام منبع استفاده شود.
|
||||
**هدف کارایی: جستجوی یک ماهه زیر نیم ثانیه.**
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
```php
|
||||
// SlotCalculatorService::getServiceStartTimes() — تکمنبعی، یک بلوک پیوسته
|
||||
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
|
||||
while ($t + $durSec <= $winEnd) {
|
||||
$conflict = $this->firstOverlap($t, $t + $needSec, $busy);
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
فقط تداخل **پزشک** بررسی میشود. اتاق، دستگاه و اپراتور اصلاً وجود ندارند.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `AvailabilityEngine` — ورودی: برنامهٔ نوبت + بازهٔ تاریخ + شعبه؛ خروجی: وقتهای معتبر
|
||||
همراه با تخصیص منبع پیشنهادی
|
||||
- تولید نقطههای شروع کاندید (پیشفرض هر ۱۵ دقیقه، قابل تنظیم per محیط)
|
||||
- هرس زودهنگام کاندیدهای قطعاً ناممکن
|
||||
- تخصیص منبع: تطبیق نیازمندیهای هر بخش به منابع آزاد
|
||||
- استراتژی انتخاب منبع: `least_gap` (پیشفرض) · `balanced` · `preserve_specialists` · `same_as_previous`
|
||||
- کش روزانهٔ پنجرهٔ آزاد هر منبع
|
||||
- endpoint عمومی و پنلی
|
||||
- حالت `booking_mode = resource` روی `WeeklySchedule` و مسیر ارتقای داوطلبانه
|
||||
|
||||
**نیست:** ثبت اشغال و رزرو موقت (تسک ۰۷)، قوانین فاصلهٔ زمانی (تسک ۰۹ — قلاب اینجا گذاشته میشود).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/appointment-availability` | جستجوی وقت با برنامه (بدنه: سرویس، آیتمها، شعبه، بازهٔ تاریخ) |
|
||||
| GET | `/api/v1/appointment-availability/month` | روزهای دارای ظرفیت در یک ماه (سبک — فقط بولین per روز) |
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: سناریوی مستند — سرویس لیزر با چهار بخش، شعبه با ۳ اتاق / ۲ اپراتور / ۳ دستگاه.
|
||||
جستجوی یک روز → لیست زمانهای شروع، و برای هر زمان `assignment` شامل اتاق، اپراتور و
|
||||
دستگاه انتخابی.
|
||||
- ✅ موفق (**آزادسازی ظرفیت — قلب کل پروژه**): بیمار الف نوبت ۱۰:۰۰-۱۱:۰۰ دارد
|
||||
(اپراتور فقط ۱۰:۰۰-۱۰:۰۵ و ۱۰:۳۵-۱۱:۰۰ درگیر است). جستجو برای بیمار ب باید زمانی
|
||||
در بازهٔ ۱۰:۰۵-۱۰:۳۵ پیدا کند اگر اتاق دومی آزاد باشد.
|
||||
**تست بدون این سناریو، تسک را تأیید نمیکند.**
|
||||
- ✅ موفق: کارایی — جستجوی ۳۰ روزه با ۲۰ منبع و ۵۰۰ نوبت ثبتشده، **زیر ۵۰۰ms**.
|
||||
تست کارایی بخشی از تسک است، نه اختیاری.
|
||||
- ✅ موفق: پزشکی که در حالت `slot` یا `service` است → این endpoint `422` با
|
||||
`ERR_WRONG_BOOKING_MODE` میدهد و مسیر قدیمی دستنخورده کار میکند.
|
||||
- ❌ خطا: بازهٔ بزرگتر از ۹۰ روز → `422`.
|
||||
- ❌ خطا: شعبهٔ محیط دیگر → `404`.
|
||||
- ❌ خطا: نیازمندی بدون منبع واجد شرایط → `422` با پیام انسانی (از تسک ۰۵).
|
||||
- ⚠️ مرزی: منبع با `capacity=3` و دو نوبت همزمان → سومی هنوز جا دارد، چهارمی نه.
|
||||
- ⚠️ مرزی: `setup/cleanup` منبع → بازهٔ اشغال گستردهتر از بازهٔ بخش است و باید در
|
||||
بررسی تداخل لحاظ شود.
|
||||
- ⚠️ مرزی: بخش با `occupancy=passive` → منبع را میگیرد ولی در گزارش بهرهوری «کار» نیست.
|
||||
- ⚠️ مرزی: نقطهٔ شروع در گذشته → حذف.
|
||||
- ⚠️ مرزی: هیچ روزی ظرفیت ندارد → آرایهٔ خالی + `reason` قابل فهم، نه ۴۰۴.
|
||||
- ⚠️ مرزی: منبع مشترک بین دو بخش غیرمجاور یک نوبت → **همان** منبع باید انتخاب شود
|
||||
(اپراتور بخش ۱ و بخش ۳ یکی است، نه دو نفر).
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Appointment/Availability/`
|
||||
- `docs/api/appointment-availability.md`
|
||||
- تست کارایی با داده مصنوعی: `tests/Appointment/AvailabilityPerformanceTest.php`
|
||||
- توسعهٔ `AppointmentSettingsPage.tsx` برای انتخاب حالت `resource` و استراتژی
|
||||
@@ -0,0 +1,134 @@
|
||||
# جریان کاربری — تسک ۰۶
|
||||
|
||||
## الف) بیمار وقت انتخاب میکند (سایت عمومی)
|
||||
|
||||
```
|
||||
[از تسک ۰۵] برنامهٔ نوبت ساخته شد: ۶۸ دقیقه، ۵ بخش
|
||||
│
|
||||
▼
|
||||
GET /api/v1/appointment-availability/month?…&month=1405-05
|
||||
→ { "1405-05-03": true, "1405-05-04": false, … }
|
||||
تقویم شمسی: روزهای بدون ظرفیت خاکستری
|
||||
│
|
||||
▼
|
||||
بیمار روز ۳ مرداد را میزند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/appointment-availability
|
||||
{
|
||||
"doctor_uuid": "…", "branch_uuid": "…",
|
||||
"service_item_uuid": "…", "option_uuids": ["…","…"],
|
||||
"from": "1405-05-03", "to": "1405-05-03", "limit": 50
|
||||
}
|
||||
▼
|
||||
{
|
||||
"data": {
|
||||
"total_minutes": 68,
|
||||
"patient_facing_minutes": 63,
|
||||
"slots": [
|
||||
{ "start": 1754…, "start_time": "09:00", "end_time": "10:08",
|
||||
"assignment": { "room": "اتاق ۲", "operator": "مریم …", "device": "کندلا ۱" } },
|
||||
{ "start": 1754…, "start_time": "10:15", … }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**بیمار `assignment` را نمیبیند.** فقط ساعت. تخصیص برای پنل و برای مرحلهٔ رزرو موقت است.
|
||||
(استثنا: اگر کلینیک «انتخاب پزشک/اپراتور توسط بیمار» را فعال کرده باشد — خارج از دامنهٔ
|
||||
این تسک.)
|
||||
|
||||
```
|
||||
▼
|
||||
بیمار ۰۹:۰۰ را میزند → تسک ۰۷ (رزرو موقت)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ب) هیچ وقتی نیست — سه پیام متفاوت
|
||||
|
||||
```
|
||||
POST /appointment-availability → data.slots = []
|
||||
data.reason = ?
|
||||
```
|
||||
|
||||
| `reason` | پیام فارسی | دکمهٔ پیشنهادی |
|
||||
|---|---|---|
|
||||
| `no_resource` | «برای این خدمت، منبع لازم در این شعبه تعریف نشده است» | (پنل) «افزودن منبع» |
|
||||
| `no_calendar` | «برای منابع این خدمت ساعت کاری تعریف نشده است» | (پنل) «تنظیم تقویم» |
|
||||
| `fully_booked` | «در بازهٔ انتخابی وقت خالی نیست» | «جستجو در ۳۰ روز آینده» |
|
||||
| `outside_window` | «رزرو آنلاین فقط تا ۳ ماه آینده ممکن است» | — |
|
||||
|
||||
پیام واحد «وقتی موجود نیست» بدترین حالت است: بیمار فکر میکند کلینیک پر است در حالی که
|
||||
کلینیک اصلاً تقویم تعریف نکرده.
|
||||
|
||||
---
|
||||
|
||||
## ج) منشی از پنل — با انتخاب دستی منبع
|
||||
|
||||
```
|
||||
پنل › نوبت جدید
|
||||
│
|
||||
├─ بیمار (جستجو یا ثبت جدید)
|
||||
├─ شعبه · سرویس · آیتمها
|
||||
│ └─ اعتبارسنجی زنده (تسک ۰۴)
|
||||
▼
|
||||
POST /appointment-availability با forManagement=true
|
||||
│ (بازهٔ رزرو آنلاین و خاموشبودن نوبتدهی اعمال نمیشود — رفتار امروزی)
|
||||
▼
|
||||
جدول وقتها با ستون «منابع پیشنهادی»
|
||||
|
||||
ساعت مدت اتاق اپراتور دستگاه
|
||||
─────────────────────────────────────────────
|
||||
۰۹:۰۰ ۶۸' اتاق ۲ ▾ مریم ▾ کندلا ۱ ▾
|
||||
۱۰:۱۵ ۶۸' اتاق ۱ ▾ سارا ▾ کندلا ۲ ▾
|
||||
|
||||
هر ▾ یک SearchableSelect است با فقط منابع آزادِ همان بازه.
|
||||
عوض کردن یکی → درخواست دوباره برای اعتبارسنجی همان زمان (نه کل لیست).
|
||||
▼
|
||||
«ثبت نوبت» → تسک ۰۷
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## د) کلینیک به حالت چندمنبعی ارتقا میدهد
|
||||
|
||||
```
|
||||
پنل › تنظیمات نوبتدهی
|
||||
│
|
||||
وضعیت فعلی: «نوبتدهی سرویسی» (قفلشده)
|
||||
│
|
||||
├─ بنر: «ارتقا به نوبتدهی چندمنبعی»
|
||||
│ ✓ ۵ منبع فعال دارید
|
||||
│ ✓ ۳ سرویس با مدت معتبر
|
||||
│ ✗ ۲ نوبت فعال در آینده دارید — ابتدا تعیین تکلیف کنید
|
||||
│ [مشاهدهٔ نوبتها]
|
||||
│
|
||||
▼ (بعد از رفع همهٔ شرطها)
|
||||
├─ ☑ میدانم این تغییر برگشتناپذیر است
|
||||
└─ [ارتقا]
|
||||
▼
|
||||
POST /api/v1/appointment-settings/upgrade-booking-mode
|
||||
▼
|
||||
حالا تنظیمات جدید فعال میشوند:
|
||||
گام زمانی: ۱۵ دقیقه ▾
|
||||
استراتژی انتخاب منبع: کمترین شکاف ▾
|
||||
```
|
||||
|
||||
چکلیست پیش از ارتقا اجباری است. بدون آن، کلینیک ارتقا میدهد، نوبتهای قدیمیاش
|
||||
نمایش نادرست میگیرند و هیچ راه بازگشتی نیست.
|
||||
|
||||
---
|
||||
|
||||
## ه) چه چیزی در این جریان **تغییر نمیکند**
|
||||
|
||||
```
|
||||
پزشک در حالت slot → GET /api/v1/appointment-slots بدون تغییر
|
||||
پزشک در حالت service → GET /api/v1/appointment-service-slots بدون تغییر
|
||||
تقویم ماهانهٔ قدیمی → GET /api/v1/appointment-settings/month-availability/{uuid} بدون تغییر
|
||||
```
|
||||
|
||||
سایت عمومی و اپ دسکتاپ تا وقتی کلینیک ارتقا نداده، هیچ کد جدیدی لازم ندارند.
|
||||
پس از ارتقا، `GET /appointment-booking-services` مقدار `booking_mode: "resource"` میدهد و
|
||||
کلاینت باید مسیر جدید را صدا بزند — **این تنها نقطهای است که کلاینتها باید بهروز شوند**
|
||||
و باید در `docs/api/appointment.md` برجسته نوشته شود.
|
||||
Reference in New Issue
Block a user