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

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

`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 و دلیلش.
