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,133 @@
# نکات پیاده‌سازی — تسک ۰۳
## ۱. `IntervalSet` اول، بقیه بعد
اولین چیزی که می‌نویسی `src/Shared/Time/IntervalSet.php` و تستش است — قبل از هر entity.
سه تسک بعدی روی درستی‌اش حساب می‌کنند و باگ مرزی در تفاضل بازه، در تسک ۰۶ به شکل
«یک اسلات عجیب» ظاهر می‌شود که دیباگش ساعت‌ها می‌برد.
مرزهایی که تست واحد باید بپوشاند:
```php
subtract([[0,100]], [[0,100]]) === [] // کامل
subtract([[0,100]], [[20,30]]) === [[0,20],[30,100]] // وسط
subtract([[0,100]], [[100,200]]) === [[0,100]] // مجاور، نه متداخل
subtract([[0,100]], [[-10,10]]) === [[10,100]] // از چپ بیرون‌زده
intersect([[0,100]], []) === [] // خالی = هیچ، نه همه‌چیز
normalize([[0,50],[50,100]]) === [[0,100]] // ادغام مجاور
normalize([[10,20],[0,5]]) === [[0,5],[10,20]] // مرتب‌سازی
```
قرارداد بازه‌ها: **نیم‌باز `[start, end)`**. همه‌جا. `end == start` یعنی بازهٔ تهی و حذف می‌شود.
## ۲. شعبهٔ بدون ساعت کاری = بی‌قید، نه بسته
```php
$branchHours = $this->branchHoursRepo->forDay($branch, $dow);
$windows = $branchHours === []
? $resourceShifts // بی‌قید
: IntervalSet::intersect($resourceShifts, $branchHours);
```
اگر برعکسش را بنویسی، همهٔ داده‌های موجود (که هیچ شعبه‌ای ساعت کاری ندارد) یک‌شبه
هیچ وقتی نمی‌دهند. این تصمیم در تسک ۰۱ هم نوشته شده — هر دو جا باید یکی باشد.
## ۳. تعطیلات: ملی، محیطی، منبعی — ترتیب
```
روز تعطیل است اگر:
(در national_holidays با is_official=true باشد
و tenant_holiday_override با is_working=true نداشته باشد)
یا
(tenant_holiday_override با is_working=false داشته باشد)
```
منبع می‌تواند با `resource_exception` روزِ باز را ببندد، ولی **نمی‌تواند** روز تعطیل را باز
کند. باز کردن فقط در سطح محیط معنی دارد (کل کلینیک آن روز کار می‌کند یا نه).
## ۴. زمان و منطقهٔ زمانی
ذخیره‌سازی همیشه Unix timestamp (UTC ذاتی). `date('w', $ts)` و `strtotime('today')` به
منطقهٔ زمانی PHP وابسته‌اند. `SlotCalculatorService` امروز روی همین فرض کار می‌کند و
`php.ini` پروژه روی `Asia/Tehran` است.
قاعده: **هیچ‌جا `date()` بدون منطقهٔ زمانی صریح ننویس** وقتی شعبه `timezone` دارد.
```php
$tz = new \DateTimeZone($branch->getTimezone());
$day = (new \DateTimeImmutable("@{$ts}"))->setTimezone($tz);
$dow = ((int) $day->format('w') + 1) % 7; // ← همان تبدیل SlotCalculatorService
```
تبدیل `(w + 1) % 7` عمداً همان است که در `SlotCalculatorService:359` هست. دو قرارداد
شمارش روز هفته در یک کدبیس = باگ قطعی.
## ۵. کارایی — پنج کوئری، نه پنج × تعداد روز
```php
// ❌ اشتباه
foreach ($days as $day) { $exceptions = $repo->findForDay($resource, $day); }
// ✅ درست — یک بار برای کل بازه، بعد در حافظه
$exceptions = $repo->findOverlapping($resource, $from, $to);
$byDay = $this->bucketByDay($exceptions, $from, $to);
```
معیار: `rawWindows()` برای یک منبع در ۹۰ روز باید **دقیقاً ۵ کوئری** بزند. یک تست با
`ProfilerStack` یا شمارندهٔ `SQLLogger` این را قفل کند — وگرنه اولین refactor آن را می‌شکند.
## ۶. سازگاری با نوبت‌دهی موجود
این تسک هیچ‌چیز از `SlotCalculatorService` را تغییر نمی‌دهد. `ResourceAvailabilityService`
یک سرویس **موازی** است که فقط در حالت `booking_mode=resource` (تسک ۰۶) صدا زده می‌شود.
تنها نقطهٔ اتصال: `HolidayResolver` می‌تواند از تسک بعد در `SlotCalculatorService` هم
استفاده شود تا پزشک‌های حالت `slot` هم تعطیلات رسمی را بگیرند. آن یک تغییر رفتاری است
(روزهایی که امروز باز بودند بسته می‌شوند) — پس **در این تسک انجام نده**، به‌عنوان یک
تغییر جدا با تأیید محصول.
## ۷. edge case ها
| حالت | رفتار درست |
|---|---|
| منبع بدون هیچ `resource_calendar` | `rawWindows` خالی، `explainEmptyDay` = `no_calendar` |
| استثنا که کل روز را می‌پوشاند | آن روز خالی |
| استثنا نیم‌روزه | فقط همان بازه کسر شود |
| دو استثنای هم‌پوشان | `union` بعد `subtract` — نه کسر پشت‌سرهم (دوبار کسر بازهٔ مشترک) |
| شیفت با `valid_to` گذشته | نادیده گرفته شود |
| شیفت شبانه (۲۲:۰۰ تا ۰۲:۰۰) | **پشتیبانی نمی‌شود در این تسک**`422` با پیام روشن. دو ردیف بنویسند |
| `from > to` | `422` |
| بازهٔ بزرگ‌تر از ۹۰ روز | `422` — سقف مستند بند ۱۰ |
| تعطیل رسمی + override محیط + استثنای منبع، هر سه روی یک روز | ترتیب بند ۳ بالا |
شیفت شبانه عمداً بیرون است: پشتیبانی‌اش یعنی هر بازهٔ روزانه ممکن است به روز بعد سرریز
کند و کل منطق bucket-by-day باید بازنویسی شود. اگر کلینیکی لازم داشت، تسک جدا.
## ۸. تست
```
tests/Shared/Time/IntervalSetTest.php ← اول این، واحد و بدون DB
tests/Resource/ResourceCalendarTest.php
- PUT شیفت‌ها، بازخوانی یکسان
- end <= start → 422 · هم‌پوشانی در یک روز → 422
- شیفت شبانه → 422
tests/Resource/ResourceAvailabilityTest.php
- منبع بدون تقویم → خالی + دلیل no_calendar
- تقاطع با ساعت شعبه
- شعبهٔ بدون ساعت → بی‌قید
- کسر استثنای نیم‌روزه
- دو استثنای هم‌پوشان → یک بار کسر
tests/Resource/AvailabilityQueryCountTest.php
- ۹۰ روز → دقیقاً ۵ کوئری
tests/Holiday/HolidayResolverTest.php
- تعطیل رسمی برای همه
- override is_working=true فقط برای همان محیط
- override is_working=false روی روز غیررسمی
tests/Holiday/ImportNationalHolidaysTest.php
- idempotent
```
## ۹. مستندات
`docs/api/resource-calendar.md` بساز. در `docs/api/appointment-settings.md` یک بخش
«تفاوت با تقویم منبع» اضافه کن تا کسی دو سیستم را قاطی نکند.