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,157 @@
# نکات پیاده‌سازی — تسک ۱۳
## ۱. پیش‌فرض بدون جریمه
`seed-default-policy` باید `penalty_mode='none'` بسازد. اگر پیش‌فرض جریمه‌دار باشد،
لحظهٔ deploy همهٔ بیماران با نوبت آیندهٔ نزدیک مشمول جریمه می‌شوند و کلینیک خبر ندارد.
فعال‌سازی جریمه یک تصمیم کسب‌وکاری صریح است، نه پیش‌فرض فنی.
## ۲. لغو توسط کلینیک هرگز جریمه ندارد
```php
if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
return PenaltyResult::free(); // ← اول از همه، پیش از هر محاسبه
}
```
این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک refactor
می‌تواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.
## ۳. سقف جریمه = مبلغ پرداختی
```php
penaltyRials: min($penalty, $paid)
```
جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ `Invoice`/`Claim` است نه لغو. برای
نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و پاسخ یک `note` می‌گیرد.
اگر روزی «بدهی لغو» لازم شد، یک تسک جدا با اتصال به `Billing` — نه یک مقدار منفی
پنهان در کیف پول.
## ۴. `setRecordedEntity` روی تراکنش کیف پول
```php
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId());
```
فراموش کردنش یعنی کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند — دقیقاً همان نشتی
که `PatientWalletTenantTest` می‌سنجد. آن تست باید بعد از این تسک هم سبز بماند.
## ۵. لیست انتظار: broadcast، با جملهٔ صریح
تصمیم معماری (جدول کامل در `architecture.md`): ظرفیت آزادشده به حداکثر ۱۰ نفر اطلاع
داده می‌شود و اولین رزروکننده می‌برد.
متن پیامک اجباراً شامل این جمله:
> «یک وقت در تاریخ X آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.»
بدون این جمله، ۹ نفر فکر می‌کنند نوبتشان تضمین شده و شکایت می‌کنند. با آن، انتظار
درست تنظیم می‌شود.
`notify_count` سقف دارد (پیشنهاد: ۳). بیماری که سه بار مطلع شده و رزرو نکرده، دیگر
پیام نمی‌گیرد تا خودش لیست را تازه کند.
## ۶. اطلاع‌رسانی async، بیرون تراکنش لغو
```php
// CancellationService — داخل تراکنش فقط dispatch
$this->events->dispatch(
(new Envelope(new AppointmentCancelled($appt->getUuid())))
->with(new DispatchAfterCurrentBusStamp())
);
// NotifyWaitlistHandler — بیرون، async
public function __invoke(AppointmentCancelled $event): void
{
$appt = $this->repo->findByUuid($event->appointmentUuid);
$this->matcher->onCapacityFreed($appt->getSlotStart(), $appt->getSlotEnd(), );
}
```
ده پیامک داخل تراکنش لغو یعنی لغو کند می‌شود و اگر پیامک شکست خورد، لغو rollback
می‌شود — که غلط است. لغو موفق است حتی اگر هیچ پیامکی نرود.
`messenger:consume async` از قبل در استک هست.
## ۷. برچسب پرریسک، نه مسدودسازی
```php
$this->tagService->attach($patient, $policy->getRiskTagUuid());
// نه: $patient->setBlocked(true)
```
مسدودسازی یک تصمیم است که کلینیک باید بگیرد، و ابزارش از قبل ساخته می‌شود: یک قانون
`eligibility` (تسک ۰۹) با شرط `patient.tags in ['پرریسک']` و اثر `deny`.
اگر اینجا مسدود کنی، دو مکانیزم موازی برای یک کار داری و کلینیک نمی‌تواند خاموشش کند.
## ۸. پنجرهٔ شمارش عدم حضور
```php
private function windowStart(): int { return time() - 365 * 86400; }
```
۱۲ ماه، نه کل تاریخ. سه عدم حضور در سال ۱۴۰۲ امروز بی‌معناست. مقدار را ثابت نگه دار
(نه تنظیم‌پذیر) تا شمارش بین کلینیک‌ها قابل مقایسه بماند؛ اگر لازم شد، ستون اضافه کن.
## ۹. edge case ها
| حالت | رفتار درست |
|---|---|
| لغو دوبارهٔ همان نوبت | idempotent — همان وضعیت، بدون جریمهٔ دوم |
| لغو نوبت گذشته | `422` — برای گذشته `no_show`/`completed` |
| جریمه > پرداختی | سقف = پرداختی + `note` |
| نوبت نقدی | جریمه صفر + `note: 'در مراجعهٔ بعدی محاسبه می‌شود'` |
| لغو توسط کلینیک | بدون جریمه، بیعانه کامل، اعتبار کامل |
| لغو جلسهٔ دوره | اعتبار **طبق سیاست**، نه همیشه؛ `CourseSession``planned` |
| بیمار در لیست انتظار که خودش نوبت گرفت | ورودی → `converted` خودکار (روی رویداد `AppointmentBooked`) |
| ظرفیت آزادشده که هیچ‌کس منتظرش نیست | هیچ کاری — لاگ debug، نه هشدار |
| ده نفر مطلع، هیچ‌کس رزرو نکرد | ورودی‌ها `waiting` می‌مانند، `notify_count++` |
| ثبت لیست انتظار برای بازهٔ گذشته | `422` |
| `desired_to - desired_from` بزرگ‌تر از ۹۰ روز | `422` — همان سقف جستجو |
| سیاست سرویس و سیاست محیط هر دو | سرویس (اختصاصی‌تر) برنده |
سطر «بیمار در لیست انتظار که خودش نوبت گرفت» را فراموش نکن: بدون آن، بیمار نوبت دارد و
همچنان پیامک «وقت آزاد شد» می‌گیرد.
## ۱۰. تست
```
tests/Cancellation/PenaltyCalculatorTest.php ← ⭐
- داخل پنجرهٔ رایگان → صفر
- بیرون پنجره → درصد درست
- لغو توسط کلینیک → همیشه صفر (حتی ۱ ساعت قبل)
- جریمه > پرداختی → سقف
- نوبت نقدی → صفر + note
tests/Cancellation/CancellationServiceTest.php
- اشغال منابع آزاد می‌شود
- جریمه در wallet_transactions با recorded_entity
- لغو دوباره → idempotent
- لغو گذشته → 422
tests/Cancellation/PolicyResolverTest.php
- سیاست سرویس بر محیط اولویت دارد
tests/Cancellation/NoShowTrackerTest.php
- سومین no_show → برچسب پرریسک
- عدم حضور قدیمی‌تر از ۱۲ ماه شمرده نمی‌شود
- همان نوبت دوبار → یک رکورد
- بیمار پرریسک مسدود نمی‌شود (رزرو موفق)
tests/Waitlist/WaitlistMatcherTest.php
- لغو → حداکثر ۱۰ نفر مطلع، به ترتیب priority سپس created_at
- فیلتر preferred_day_parts
- notify_count سقف دارد
tests/Waitlist/WaitlistConversionTest.php
- بیمار خودش نوبت گرفت → converted
tests/Waitlist/WaitlistAsyncTest.php
- شکست پیامک، لغو را rollback نمی‌کند
tests/Patient/PatientWalletTenantTest.php ← موجود، باید سبز بماند
tests/Course/CourseLifecycleTest.php ← موجود، سیاست اعتبار اعمال شود
```
## ۱۱. مستندات
`docs/api/cancellation.md` و `docs/api/waitlist.md`. در اولی حتماً بنویس که
`cancellation-preview` پیش از لغو اجباری است و لغو توسط کلینیک هرگز جریمه ندارد.
در دومی تصمیم broadcast و دلیلش.