# معماری — تسک ۱۳

## ساختار فایل

```
src/Cancellation/
├── Entity/{CancellationPolicy, NoShowRecord}.php
├── Service/
│   ├── CancellationPolicyResolver.php   # اختصاصی‌ترین سیاست
│   ├── PenaltyCalculator.php
│   ├── CancellationService.php          # ارکستراتور لغو
│   └── NoShowTracker.php
└── Controller/CancellationController.php

src/Waitlist/
├── Entity/WaitlistEntry.php
├── Service/
│   ├── WaitlistService.php
│   └── WaitlistMatcher.php              # تطبیق ظرفیت آزاد با درخواست‌ها
├── MessageHandler/NotifyWaitlistHandler.php
└── Controller/WaitlistController.php
```

## `CancellationPolicy`

```php
class CancellationPolicy
{
    use TenantOwnedTrait;

    private ?ServiceItem $service = null;      // null = پیش‌فرض محیط
    private int  $freeWindowHours = 24;        // تا چند ساعت قبل، رایگان
    private string $penaltyMode = 'percent';   // none | percent | fixed
    private int  $penaltyValue = 0;
    private bool $depositRefundable = false;   // پس از پنجرهٔ رایگان
    private bool $creditRefundable = true;     // اعتبار پکیج (تسک ۱۱)
    private int  $noShowThreshold = 3;         // بعد از چند بار، برچسب پرریسک
    private ?string $riskTagUuid = null;       // TenantTag موجود
}
```

`riskTagUuid` به `TenantTag` موجود اشاره می‌کند، نه یک ستون `is_risky` روی بیمار.
دلیل: سیستم برچسب از قبل هست، در `DiscountRule.target_tag_uuid` و `FieldRegistry`
(`patient.tags`) استفاده می‌شود، و قانون `eligibility` تسک ۰۹ می‌تواند رویش شرط بگذارد.
ستون بولین جدید یعنی یک مفهوم موازی که هیچ‌کدام از آن‌ها نمی‌بینند.

## `PenaltyCalculator`

```php
public function forCancellation(Appointment $appt, string $by, int $now): PenaltyResult
{
    // لغو توسط کلینیک: هرگز جریمه
    if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
        return PenaltyResult::free();
    }

    $policy = $this->resolver->forAppointment($appt);
    $hoursLeft = intdiv($appt->getSlotStart() - $now, 3600);

    if ($hoursLeft >= $policy->getFreeWindowHours()) {
        return PenaltyResult::free();
    }

    $paid    = $this->paymentRepo->totalPaidFor($appt);
    $penalty = match ($policy->getPenaltyMode()) {
        'percent' => intdiv($this->snapshotFinal($appt) * $policy->getPenaltyValue(), 100),
        'fixed'   => $policy->getPenaltyValue(),
        default   => 0,
    };

    return new PenaltyResult(
        penaltyRials: min($penalty, $paid),          // ← سقف: مبلغ پرداختی
        depositRefundable: $policy->isDepositRefundable(),
        creditRefundable: $policy->isCreditRefundable(),
    );
}
```

`min($penalty, $paid)` مهم است: جریمهٔ بیشتر از پرداختی یعنی بدهی — که مسئلهٔ حسابداری
است، نه لغو. برای نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و در پاسخ یک
`note: 'جریمه در مراجعهٔ بعدی محاسبه می‌شود'` می‌آید.

## `cancellation-preview` — اجباری پیش از لغو

```
GET /appointment/{uuid}/cancellation-preview
    ▼
{
  "hours_left": 6,
  "free_window_hours": 24,
  "penalty_rials": 1200000,
  "deposit_refundable": false,
  "credit_refundable": true,
  "refund_rials": 800000,
  "message": "لغو در کمتر از ۲۴ ساعت باقی‌مانده ۵۰٪ جریمه دارد."
}
```

بدون این endpoint، کاربر لغو می‌کند و بعد جریمه می‌بیند. UI باید preview را در
`ConfirmDialog` نشان دهد.

## `CancellationService` — ترتیب

```php
$this->em->wrapInTransaction(function () use ($appt, $by, $reason) {
    $penalty = $this->penalty->forCancellation($appt, $by, time());

    $this->transition($appt, $by);                            // ۱ وضعیت
    $this->occupancyWriter->release($appt);                    // ۲ آزادسازی منابع (تسک ۰۷)
    $this->refundDeposit($appt, $penalty);                     // ۳ بیعانه
    $this->chargePenalty($appt, $penalty);                     // ۴ جریمه در wallet_transactions
    $this->refundCredit($appt, $penalty);                      // ۵ اعتبار پکیج (تسک ۱۱)
    $this->courseLinker->releaseSession($appt);                // ۶ جلسهٔ دوره (تسک ۱۲)
    $this->events->dispatch(new AppointmentCancelled($appt->getUuid()));  // ۷ بعد از commit
});
```

مرحلهٔ ۷ رویداد است که `NotifyWaitlistHandler` به آن گوش می‌دهد — لیست انتظار async
مطلع می‌شود، نه در تراکنش لغو.

## `WaitlistEntry`

```php
class WaitlistEntry
{
    use TenantOwnedTrait;
    private PatientRecord $patient;
    private ServiceItem $service;
    private ?Branch $branch = null;
    private int $desiredFrom;                 // بازهٔ دلخواه
    private int $desiredTo;
    private array $preferredDayParts = [];    // ['morning','afternoon','evening']
    private int  $priority = 0;
    private ?int $notifiedAt = null;
    private int  $notifyCount = 0;
    private string $status = 'waiting';       // waiting | notified | converted | expired
}
```

## `WaitlistMatcher` — همه مطلع می‌شوند، صف انحصاری نه

```php
public function onCapacityFreed(int $from, int $to, ServiceItem $service, ?Branch $branch): void
{
    $matches = $this->repo->findMatching($from, $to, $service, $branch, limit: 10);
    foreach ($matches as $entry) {
        $this->bus->dispatch(new NotifyWaitlistMessage($entry->getUuid()));
    }
}
```

**تصمیم: broadcast، نه قفل انحصاری.**

| گزینه | مشکل |
|---|---|
| قفل انحصاری برای نفر اول (مثلاً ۳۰ دقیقه) | نفر اول ممکن است شب باشد و پیام را نبیند؛ ظرفیت ۳۰ دقیقه بلوکه و بعد نفر دوم، و همین‌طور — یک ساعت خالی می‌تواند سه ساعت معطل بماند |
| **اطلاع به همه، اولین رزروکننده می‌برد** ✅ | ظرفیت سریع پر می‌شود؛ هزینه‌اش این است که چند نفر پیام می‌گیرند و جا نیست |

هزینهٔ گزینهٔ دوم با یک جملهٔ صریح در پیامک قابل مدیریت است:
«یک وقت آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.»

سقف ۱۰ نفر برای جلوگیری از انبوه پیامک. `priority` ترتیب را تعیین می‌کند (بیمار وفادار
یا پکیج‌دار می‌تواند اولویت بگیرد).

## `NoShowTracker`

```php
public function record(Appointment $appt): void
{
    $this->em->persist(new NoShowRecord($appt));
    $count = $this->repo->countForPatient($appt->patientRecord(), since: $this->windowStart());
    $policy = $this->resolver->forTenant($appt->tenantPair());

    if ($count >= $policy->getNoShowThreshold() && $policy->getRiskTagUuid() !== null) {
        $this->tagService->attach($appt->patientRecord(), $policy->getRiskTagUuid());
    }
}
```

پنجرهٔ شمارش: ۱۲ ماه گذشته (نه کل عمر). بیماری که سه سال پیش سه بار نیامده، امروز
پرریسک نیست.

## پنل ادمین

- `CancellationPolicyPage.tsx` — سیاست محیط + جدول override سرویس‌ها
- `WaitlistPage.tsx` — لیست درخواست‌ها با فیلتر بازه/سرویس، و تب «قابل تطبیق» که
  ظرفیت‌های آزاد شده و کاندیدهایشان را نشان می‌دهد
- در `AppointmentDetailPage.tsx` دکمهٔ لغو → `ConfirmDialog` با محتوای preview
- در `PatientDetailPage.tsx` نشان «پرریسک» + شمارش عدم حضور
- `ReserveAppointmentsPage.tsx` موجود می‌ماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
  ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است
