# Cancellation — سیاست لغو، جریمه و عدم حضور

اندپوینت‌های `src/Cancellation/*`. مستند بند ۱۱: «هر کلینیک تنظیم می‌کند تا چند ساعت قبل
لغو رایگان است، جریمه چقدر است، بیعانه برمی‌گردد یا نه، و بعد از چند بار عدم حضور بیمار
پرریسک علامت بخورد.»

همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر).

---

## دو قاعده‌ای که شکستنشان گران است

۱. **لغو توسط کلینیک هرگز جریمه ندارد.** این شرط اولین خط محاسبه است، نه جایی وسط آن —
   اگر بعد از بررسی پنجرهٔ زمانی می‌آمد، یک refactor می‌توانست ترتیب را عوض کند و کلینیک
   از بیمار برای لغو خودش جریمه بگیرد.
۲. **جریمه هرگز از مبلغ پرداختی بیشتر نمی‌شود.** جریمهٔ بیشتر یعنی بدهی، و بدهی مسئلهٔ
   صورتحساب است نه لغو. برای نوبت نقدی (پرداختی صفر) جریمه صفر می‌شود و پاسخ توضیحش را
   در `notes` می‌دهد.

**پیش‌فرض بدون جریمه است.** اگر پیش‌فرض جریمه‌دار بود، لحظهٔ deploy همهٔ بیماران با نوبت
نزدیک مشمول جریمه می‌شدند و کلینیک خبر نداشت. فعال‌کردن جریمه یک تصمیم کسب‌وکاری صریح است.

---

## GET `/api/v1/cancellation-policy`

```json
{
  "success": true,
  "data": {
    "default": {
      "uuid": "…",
      "service_uuid": null,
      "free_window_hours": 24,
      "penalty_mode": "percent",
      "penalty_value": 50,
      "deposit_refundable": false,
      "credit_refundable": true,
      "no_show_threshold": 3,
      "risk_tag_uuid": null,
      "active": true,
      "created_at": 1785486000
    },
    "overrides": []
  }
}
```

## PUT `/api/v1/cancellation-policy` · PUT `/api/v1/service-item/{uuid}/cancellation-policy`

| Field | Type | Description |
|---|---|---|
| `free_window_hours` | int | تا چند ساعت قبل، لغو رایگان است |
| `penalty_mode` | string | `none` \| `percent` \| `fixed` |
| `penalty_value` | int | درصد ۰..۱۰۰ یا مبلغ ریالی |
| `deposit_refundable` | bool | پس از پنجرهٔ رایگان |
| `credit_refundable` | bool | اعتبار پکیج (تسک ۱۱) |
| `no_show_threshold` | int | بعد از چند بار عدم حضور، برچسب پرریسک |
| `risk_tag_uuid` | string\|null | برچسبی از `tenant_tags` |
| `active` | bool | |

سیاستِ سرویس و سیاستِ محیط **ترکیب نمی‌شوند**: اگر سرویس سیاست فعال دارد، همان کامل
برنده است. «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی است که هیچ اپراتوری نمی‌تواند در
ذهنش شبیه‌سازی کند.

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | درصد بیرون بازهٔ ۰..۱۰۰ یا حالت جریمهٔ ناشناخته |

---

## GET `/api/v1/appointment/{uuid}/cancellation-preview`

| Query | Type | Description |
|---|---|---|
| `by` | string | `doctor` برای لغو از سمت کلینیک؛ پیش‌فرض بیمار |

```json
{
  "success": true,
  "data": {
    "penalty_rials": 2000000,
    "deposit_refundable": false,
    "credit_refundable": true,
    "within_free_window": false,
    "notes": [],
    "paid_rials": 4000000
  }
}
```

همان محاسبه‌ای که خودِ لغو انجام می‌دهد — بیمار نباید عددی ببیند که با آنچه کسر می‌شود
فرق دارد.

---

## POST `/api/v1/appointment/{uuid}/cancel`

```json
{ "by": "doctor", "reason": "بیمار تماس گرفت" }
```

`by` اختیاری است؛ نبودنش یعنی لغو از سمت بیمار. `reason` هم اختیاری است و روی تایم‌لاین
نوبت (`AppointmentEvent`) می‌نشیند — همان چیزی که اپراتور در صفحهٔ نوبت می‌بیند. بدون آن،
لغو از مسیر سیاست هیچ ردی در تاریخچهٔ نوبت نمی‌گذاشت.

پنل این اندپوینت را از دکمهٔ «لغو نوبت» صدا می‌زند و **پیش از تأیید** نتیجهٔ
`cancellation-preview` را نشان می‌دهد؛ عددی که اپراتور می‌بیند همان است که کسر می‌شود.

### Response `200`
```json
{
  "success": true,
  "data": {
    "appointment_uuid": "…",
    "status": "cancelled_by_user",
    "released_resources": 3,
    "waitlist_notified": 2,
    "penalty_charged": true,
    "penalty_rials": 1000000,
    "deposit_refundable": false,
    "credit_refundable": true,
    "within_free_window": false,
    "notes": []
  }
}
```

ترتیب کارها: اعتبارسنجی → آزادسازی ظرفیت و بازگشت اعتبار → کسر جریمه → اطلاع به لیست
انتظار. اگر اطلاع‌رسانی اول بود، ده نفر برای ظرفیتی خبر می‌شدند که هنوز آزاد نشده.

**موجودی ناکافی لغو را شکست نمی‌دهد:** `penalty_charged: false` برمی‌گردد ولی نوبت آزاد
می‌شود. نوبت نباید گروگان پول بماند.

سیاستی که `credit_refundable: false` دارد، اعتبارِ برگشتهٔ پکیج را با یک ردیف
`adjustment` منفی پس می‌گیرد — ردیف `refund` حذف نمی‌شود، چون دفتر append-only است.

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_SLOT_TAKEN` | 409 | نوبت قبلاً لغو شده |
| `ERR_VALIDATION_001` | 422 | نوبت گذشته — برای گذشته `no_show` یا `completed` معنا دارد |

---

## GET `/api/v1/patient/{uuid}/no-shows`

خلاصهٔ عدم حضور یک بیمار — همان چیزی که پروندهٔ او نشان می‌دهد.

```json
{ "success": true, "data": { "count": 3, "threshold": 3, "window_days": 365, "at_risk": true } }
```

`at_risk` یک **نشانه** است، نه مانع. مسدودسازی کارِ قانون `eligibility` تسک ۰۹ روی همین
برچسب است؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود
این شمارش را خاموش کند. تست `testATaggedPatientCanStillBook` همین را پین می‌کند.

پنل نشان را فقط وقتی می‌آورد که `count > 0` باشد — «۰ غیبت» روی پروندهٔ هر بیمار سالم،
اتهام بی‌جاست.

**۴۰۴** برای بیمار محیط دیگر.

## POST `/api/v1/appointment/{uuid}/no-show`

```json
{
  "success": true,
  "data": { "recorded": true, "count": 3, "threshold": 3, "tagged": true }
}
```

هر عدم حضور یک **ردیف** است نه یک شمارنده: شمارنده «چه زمانی و کدام نوبت» را از دست
می‌دهد و پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه می‌کند. بیماری که سه سال پیش سه بار نیامده،
امروز پرریسک نیست.

ثبت دوباره روی همان نوبت `recorded: false` می‌دهد و شمارش را بالا نمی‌برد.

⚠️ **برچسب پرریسک مسدود نمی‌کند.** مسدودسازی یک قانون `eligibility` (تسک ۰۹) روی همین
برچسب است. تفکیکش عمدی است: کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد،
نباید مجبور شود برچسب را خاموش کند.

---

## طبقه‌بندی محیط

| جدول | وضعیت |
|---|---|
| `cancellation_policies` · `no_show_records` | جفت محیط |

تراکنش جریمه در کیف پول با `entity_type`/`entity_id` نوبت ثبت می‌شود، وگرنه کلینیک الف
جریمهٔ ثبت‌شده در کلینیک ب را می‌دید.

## تست‌ها

```bash
ddev exec php bin/phpunit tests/Cancellation   # ۱۷ تست
```
