# نکات پیاده‌سازی — تسک ۱۳ ## ۱. پیش‌فرض بدون جریمه `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 و دلیلش.