Files
clinicpro/docs/new_feture/taskes/task-13-cancellation-waitlist/implementation_notes.md
T
hamed 021d0eb6b2 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.
2026-07-30 11:43:58 +03:30

7.9 KiB

نکات پیاده‌سازی — تسک ۱۳

۱. پیش‌فرض بدون جریمه

seed-default-policy باید penalty_mode='none' بسازد. اگر پیش‌فرض جریمه‌دار باشد، لحظهٔ deploy همهٔ بیماران با نوبت آیندهٔ نزدیک مشمول جریمه می‌شوند و کلینیک خبر ندارد.

فعال‌سازی جریمه یک تصمیم کسب‌وکاری صریح است، نه پیش‌فرض فنی.

۲. لغو توسط کلینیک هرگز جریمه ندارد

if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
    return PenaltyResult::free();   // ← اول از همه، پیش از هر محاسبه
}

این شرط باید اولین خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک refactor می‌تواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.

۳. سقف جریمه = مبلغ پرداختی

penaltyRials: min($penalty, $paid)

جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ Invoice/Claim است نه لغو. برای نوبت نقدی ($paid = 0) جریمه صفر می‌شود و پاسخ یک note می‌گیرد.

اگر روزی «بدهی لغو» لازم شد، یک تسک جدا با اتصال به Billing — نه یک مقدار منفی پنهان در کیف پول.

۴. setRecordedEntity روی تراکنش کیف پول

$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId());

فراموش کردنش یعنی کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند — دقیقاً همان نشتی که PatientWalletTenantTest می‌سنجد. آن تست باید بعد از این تسک هم سبز بماند.

۵. لیست انتظار: broadcast، با جملهٔ صریح

تصمیم معماری (جدول کامل در architecture.md): ظرفیت آزادشده به حداکثر ۱۰ نفر اطلاع داده می‌شود و اولین رزروکننده می‌برد.

متن پیامک اجباراً شامل این جمله:

«یک وقت در تاریخ X آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.»

بدون این جمله، ۹ نفر فکر می‌کنند نوبتشان تضمین شده و شکایت می‌کنند. با آن، انتظار درست تنظیم می‌شود.

notify_count سقف دارد (پیشنهاد: ۳). بیماری که سه بار مطلع شده و رزرو نکرده، دیگر پیام نمی‌گیرد تا خودش لیست را تازه کند.

۶. اطلاع‌رسانی async، بیرون تراکنش لغو

// 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 از قبل در استک هست.

۷. برچسب پرریسک، نه مسدودسازی

$this->tagService->attach($patient, $policy->getRiskTagUuid());
// نه: $patient->setBlocked(true)

مسدودسازی یک تصمیم است که کلینیک باید بگیرد، و ابزارش از قبل ساخته می‌شود: یک قانون eligibility (تسک ۰۹) با شرط patient.tags in ['پرریسک'] و اثر deny.

اگر اینجا مسدود کنی، دو مکانیزم موازی برای یک کار داری و کلینیک نمی‌تواند خاموشش کند.

۸. پنجرهٔ شمارش عدم حضور

private function windowStart(): int { return time() - 365 * 86400; }

۱۲ ماه، نه کل تاریخ. سه عدم حضور در سال ۱۴۰۲ امروز بی‌معناست. مقدار را ثابت نگه دار (نه تنظیم‌پذیر) تا شمارش بین کلینیک‌ها قابل مقایسه بماند؛ اگر لازم شد، ستون اضافه کن.

۹. edge case ها

حالت رفتار درست
لغو دوبارهٔ همان نوبت idempotent — همان وضعیت، بدون جریمهٔ دوم
لغو نوبت گذشته 422 — برای گذشته no_show/completed
جریمه > پرداختی سقف = پرداختی + note
نوبت نقدی جریمه صفر + note: 'در مراجعهٔ بعدی محاسبه می‌شود'
لغو توسط کلینیک بدون جریمه، بیعانه کامل، اعتبار کامل
لغو جلسهٔ دوره اعتبار طبق سیاست، نه همیشه؛ CourseSessionplanned
بیمار در لیست انتظار که خودش نوبت گرفت ورودی → 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 و دلیلش.