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:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -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` برجسته نوشته شود.