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,131 @@
# نکات پیاده‌سازی — تسک ۰۵
## ۱. برنامه یک تابع خالص است
`AppointmentPlanBuilder::build()` نباید چیزی بنویسد، چیزی cache کند، یا به `time()` نگاه کند.
ورودی یکسان → خروجی یکسان. دلیلش تسک ۰۶ است: موتور جستجو یک برنامه می‌سازد و آن را
برای ۹۰ روز × ده‌ها نقطهٔ شروع استفاده می‌کند. اگر ساختن برنامه عوارض جانبی داشته باشد،
جستجو یا کند می‌شود یا نتیجهٔ ناپایدار می‌دهد.
تنها I/O مجاز: خواندن الگوها و منابع کاندید (مرحلهٔ ۶).
## ۲. `offset_minutes` نسبی است، نه مطلق
بخش‌ها با فاصله از **شروع نوبت** ذخیره می‌شوند، نه timestamp:
```
بخش ۱ — offset 0, duration 5
بخش ۲ — offset 5, duration 30
بخش ۳ — offset 35, duration 20
بخش ۴ — offset 55, duration 5
```
تسک ۰۶ همین برنامه را روی هر نقطهٔ شروع کاندید «می‌لغزاند». اگر offset مطلق بود، برای هر
نقطهٔ شروع باید برنامه از نو ساخته می‌شد.
## ۳. `setup/cleanup` منبع کجا اعمال می‌شود
**نه در برنامه، در اشغال.** برنامه می‌گوید «اپراتور از دقیقهٔ ۳۵ تا ۵۵ لازم است». اشغال
واقعی همان منبع، با `setup=5, cleanup=10`، از دقیقهٔ ۳۰ تا ۶۵ است.
پس `PlannedRequirement` دو مقدار می‌دهد:
```php
public readonly int $startOffset; // ۳۵ — چیزی که به بیمار نشان داده می‌شود
public readonly int $endOffset; // ۵۵
public readonly int $occupancyStartOffset; // ۳۰ — چیزی که تسک ۰۶/۰۷ استفاده می‌کند
public readonly int $occupancyEndOffset; // ۶۵
```
محاسبه‌شان اینجا انجام می‌شود چون `setup/cleanup` per منبع کاندید متفاوت است و باید
بعد از مرحلهٔ ۶ (پیدا کردن کاندیدها) حساب شود — با بیشینهٔ کاندیدها، تا برنامه محافظه‌کار
بماند و تسک ۰۶ بعد از انتخاب منبع دقیقش کند.
## ۴. ادغام: `count` بیشینه، نه جمع
وقتی دو بخش هم‌نوع ادغام می‌شوند و هر دو «۱ اپراتور» می‌خواهند، نتیجه **۱** اپراتور است،
نه ۲. همان اپراتور هر دو کار را می‌کند.
جمع فقط وقتی درست است که دو کار واقعاً هم‌زمان انجام شوند — که در بخش‌های ادغام‌شده
(که ذاتاً یک کار شده‌اند) صدق نمی‌کند.
## ۵. قید جنسیت — سکوت ممنوع
```php
if (($req->constraints['same_gender_as_patient'] ?? false) && $patient?->getGender() === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای این سرویس ثبت جنسیت بیمار الزامی است',
422
);
}
```
نادیده گرفتنِ قید وقتی داده نیست، بدترین حالت است: در کلینیک زیبایی ایران این یک الزام
جدی است (مستند بند ۶) و نقض خاموشش یعنی بیمار سر قرار با اپراتور نامناسب روبه‌رو می‌شود.
## ۶. `occupancy` سه حالت — تفاوت عملی
| حالت | در تسک ۰۶ | در تسک ۰۷ |
|---|---|---|
| `exclusive` | منبع باید کاملاً آزاد باشد | یک ردیف اشغال با `units = capacity` |
| `shared` | `COUNT(اشغال‌های فعال) < capacity` | یک ردیف با `units = 1` |
| `passive` | مثل `exclusive` | ردیف اشغال با پرچم `passive` — گزارش بهره‌وری آن را «کارِ فعال» حساب نمی‌کند |
اینجا فقط ستون و اعتبارسنجی است؛ معنی‌شان در ۰۶ و ۰۷ پیاده می‌شود. ولی تفاوت را همین
حالا در `docs/api/appointment-plan.md` بنویس.
## ۷. سقف‌ها — حفاظت از تسک ۰۶
| سقف | مقدار | چرا |
|---|---|---|
| تعداد بخش یک برنامه | ۲۰ | هر بخش یعنی یک بررسی تداخل per نقطهٔ شروع |
| مجموع مدت برنامه | ۴۸۰ دقیقه | برنامهٔ طولانی‌تر عملاً هیچ روزی جا نمی‌شود |
| تعداد نیازمندی هر بخش | ۱۰ | |
| تعداد آیتم انتخابی | ۲۰ | (تسک ۰۴ هم `max_select` دارد، این سقف کلی است) |
نقض → `422` با پیام روشن، نه تلاش برای محاسبه. مستند بند ۱۷ ریسک «کند شدن جستجو با
زیاد شدن منابع» را اولین ریسک می‌داند؛ سقف‌ها ارزان‌ترین دفاع‌اند.
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| سرویس بدون الگو | یک بخش مجازی با منبع `type=doctor` — رفتار امروزی |
| بخش با `requirements = []` | معتبر؛ زمان می‌گیرد، منبع نمی‌گیرد |
| همهٔ بخش‌ها `fixed_minutes` و آیتم‌ها مدت دارند | مدت آیتم‌ها **نادیده** نمی‌رود: اگر هیچ بخش سهمی نباشد و آیتم مدت داشته باشد → `422` هنگام ذخیرهٔ الگو |
| `duration_share` جمعش ۹۹ | `422` هنگام ذخیره |
| دو بخش با یک `sequence` | ترتیب بر اساس `id` پایدار شود، نه خطا |
| بخش آیتمی که آیتمش انتخاب نشده | در برنامه نمی‌آید |
| `specific_resource` غیرفعال | `candidates=[]` → خطای انسانی |
| استخر خالی | همان |
| بیمار مهمان (بدون پرونده) و قید جنسیت | جنسیت از فرم رزرو (`patient_gender` روی `Appointment` هست) گرفته شود |
## ۹. تست
```
tests/Appointment/Plan/SegmentAssemblerTest.php
- جمع‌آوری بخش سرویس + بخش آیتم
- ادغام دو بخش هم‌نوع mergeable → یکی، count بیشینه نه جمع
- بخش غیر-mergeable هم‌نوع → جدا می‌ماند
tests/Appointment/Plan/SegmentDurationResolverTest.php
- fixed ثابت می‌ماند وقتی آیتم‌ها زیاد شوند
- سهمی با مدت محاسبه‌شدهٔ تسک ۰۴ مقیاس می‌گیرد
- جمع مدت بخش‌ها = total_minutes
tests/Appointment/Plan/AppointmentPlanBuilderTest.php
- سناریوی کامل مستند: چهار بخش، offset های ۰/۵/۳۵/۵۵، total=60
- سرویس بدون الگو → یک بخش با منبع doctor (سازگاری)
- قطعیت: دو بار build با ورودی یکسان → خروجی یکسان
tests/Appointment/Plan/RequirementResolverTest.php
- مهارت موجود → کاندید
- هیچ کاندید → NoEligibleResourceException با پیام شامل نقش و مهارت
- قید جنسیت با بیمار بدون جنسیت → 422
- منبع محیط دیگر هرگز کاندید نمی‌شود
tests/Appointment/Plan/PlanLimitsTest.php
- ۲۱ بخش → 422 · ۴۸۱ دقیقه → 422
```
## ۱۰. مستندات
`docs/api/appointment-plan.md` بساز — شامل جدول سه حالت اشغال، مثال کامل خروجی
`preview`، و توضیح تفاوت `offset` نمایشی با `occupancy_offset`.