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,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 و دلیلش.
|
||||
Reference in New Issue
Block a user