# Package — پکیج و دفتر اعتبار جلسات

اندپوینت‌های `src/Package/*`. «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است: بیمار
یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند.

**اعتبار یک دفتر حساب است، نه یک شمارنده.** هیچ ستون `remaining` یا `used_count` در هیچ
جدولی وجود ندارد و مانده همیشه `SUM(delta)` ردیف‌های دفتر است. ردیف‌ها append-only اند؛
تصحیح یعنی ردیف تازه، نه ویرایش ردیف قبلی.

همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط
دیگر). `adjust` و `expire` علاوه بر آن نقش پزشک/کلینیک/ادمین می‌خواهند (`403` برای منشی).

---

## چرخهٔ اعتبار

| نوع ردیف | delta | کِی نوشته می‌شود |
|---|---|---|
| `purchase` | `+session_count` | فروش پکیج به بیمار |
| `consume` | `-1` | `BookingService::confirm()` — ثبت نهایی نوبت |
| `refund` | `+1` | لغو همان نوبت؛ ردیف `consume` **حذف نمی‌شود** |
| `adjustment` | `±n` | اصلاح دستی، همیشه با `reason` و `created_by` |
| `expiry` | `-balance` | `app:package:expire` یا ابطال دستی |

`UNIQUE(appointment_id, kind)` مصرف دوباره را می‌بندد: `confirm` idempotent است و
اجرای دومش جلسهٔ دوم نمی‌خورد.

**FIFO:** وقتی بیمار چند پکیج معتبر برای یک سرویس دارد، قدیمی‌ترین اول مصرف می‌شود —
چون به انقضا نزدیک‌تر است و نگه داشتنش یعنی بیمار پولش را از دست بدهد.

---

## GET `/api/v1/packages`

| Query | Type | Description |
|---|---|---|
| `active` | bool | فقط فعال‌ها یا فقط غیرفعال‌ها |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "5502aa44-213a-44bd-9b24-64094c675f6e",
      "name": "۶ جلسه لیزر فول‌بادی",
      "session_count": 6,
      "price_rials": 25000000,
      "validity_days": 365,
      "active": true,
      "services": [{ "uuid": "be2e7a93-…", "name": "لیزر فول‌بادی" }],
      "created_at": 1785483226
    }
  ]
}
```

---

## POST `/api/v1/packages`

### Request Body
```json
{
  "name": "۶ جلسه لیزر فول‌بادی",
  "session_count": 6,
  "price_rials": 25000000,
  "validity_days": 365,
  "service_uuids": ["be2e7a93-976a-468b-aa7a-b8f4a4278953"]
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | ✅ | |
| `session_count` | int | ✅ | حداقل ۱ |
| `price_rials` | int | — | ریال؛ ستون `bigint` است |
| `validity_days` | int\|null | — | اعتبار از تاریخ خرید؛ `null` = بی‌پایان |
| `service_uuids` | string[] | ✅ | حداقل یک سرویس از همین محیط |
| `active` | bool | — | پیش‌فرض `true` |

### Response `201`
همان شکل بالا، با `uuid` تازه.

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | نام خالی، `session_count < 1`، یا `service_uuids` خالی |
| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری |

> پکیج بدون سرویس هرگز قابل مصرف نیست؛ ساختنش فقط یک تلهٔ خاموش برای اپراتور است،
> پس `422` می‌گیرد.

---

## GET · PATCH · DELETE `/api/v1/package/{uuid}`

`PATCH` همان فیلدهای ساخت را می‌پذیرد. فرستادن `service_uuids` **جایگزینی کامل** است.

`DELETE` پکیج را **غیرفعال** می‌کند، حذف نمی‌کند: ردیف‌های دفترِ بیماران به آن ارجاع
دارند و حذفش تاریخچهٔ اعتبار را بی‌معنا می‌کند.

---

## POST `/api/v1/patient/{uuid}/package`

فروش پکیج به بیمار. یک ردیف `purchase` هم‌زمان نوشته می‌شود.

### Request Body
```json
{ "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 }
```

`price_paid_rials` اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید.

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "dcd21f8e-ddee-4f3a-96fb-9fd6c1867a27",
    "package_uuid": "5502aa44-…",
    "package_name": "۶ جلسه لیزر فول‌بادی",
    "patient_uuid": "5faa22e0-…",
    "session_count": 6,
    "price_paid_rials": 25000000,
    "purchased_at": 1785483226,
    "valid_to": 1817019226,
    "expired": false,
    "balance": 6
  }
}
```

`session_count` و `price_paid_rials` **کپی**اند نه ارجاع: تغییر تعریف پکیج فردا، پکیج
فروخته‌شدهٔ دیروز را عوض نمی‌کند.

---

## GET `/api/v1/patient/{uuid}/packages`

پکیج‌های بیمار با ماندهٔ روز. پکیج منقضی `balance: 0` نشان می‌دهد ولی دفترش دست‌نخورده
می‌ماند.

---

## GET `/api/v1/patient-package/{uuid}/ledger`

### Response `200`
```json
{
  "success": true,
  "data": {
    "package": { "uuid": "dcd21f8e-…", "balance": 7, "…": "مثل بالا" },
    "rows": [
      {
        "uuid": "3d11dc1f-…",
        "kind": "purchase",
        "delta": 6,
        "appointment_uuid": null,
        "service_uuid": null,
        "service_name": null,
        "reason": "خرید پکیج «۶ جلسه لیزر فول‌بادی»",
        "created_by": "b093deb7-…",
        "created_at": 1785483226,
        "running_balance": 6
      },
      {
        "uuid": "ca4ab9fb-…",
        "kind": "adjustment",
        "delta": 1,
        "reason": "جبران جلسهٔ لغوشده",
        "created_by": "b093deb7-…",
        "created_at": 1785483226,
        "running_balance": 7
      }
    ]
  }
}
```

`running_balance` جمع تجمعی است و در پاسخ محاسبه می‌شود — همین به کاربر نشان می‌دهد
عدد نهایی از کجا آمده.

---

## POST `/api/v1/patient-package/{uuid}/adjust`

**Permission:** پزشک · کلینیک · ادمین (منشی `403`)

### Request Body
```json
{ "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" }
```

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | `delta` صفر یا غایب، یا `reason` خالی |
| `ERR_VALIDATION_001` | 422 | اصلاحی که مانده را منفی می‌کند |
| `ERR_FORBIDDEN_001` | 403 | نقش منشی |

```json
{
  "success": false,
  "data": null,
  "errors": [{ "code": "ERR_VALIDATION_002", "message": "دلیل اصلاح الزامی است", "field": "reason" }]
}
```

---

## POST `/api/v1/patient-package/{uuid}/expire`

ابطال دستی: یک ردیف `expiry` با `delta = -balance`. ماندهٔ صفر → `422`.

---

## اثر پکیج روی قیمت

`POST /api/v1/pricing/quote` یک فیلد اختیاری `patient_uuid` می‌گیرد. با آن، اگر بیمار
پکیج معتبری برای همان سرویس داشته باشد **قیمت پایهٔ سرویس** پوشش داده می‌شود:

```json
{
  "base_rials": 5000000,
  "final_rials": 0,
  "package_will_be_consumed": true,
  "package_uuid": "dcd21f8e-…",
  "breakdown": {
    "discounts": [
      { "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" }
    ]
  }
}
```

⚠️ **`quote` هیچ‌وقت مصرف نمی‌کند** — فقط اعلام می‌کند. مصرف واقعی در
`BookingService::confirm()` است. اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از
بیمار می‌گرفت.

پکیج **قیمت پایه** را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ
لیزر، نه هر چیزی که کنارش انتخاب شود.

ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد.

---

## دستور انقضا

```bash
ddev exec php bin/console app:package:expire            # روزانه
ddev exec php bin/console app:package:expire --dry-run
```

برای هر پکیج با `valid_to` گذشته و ماندهٔ مثبت، یک ردیف `expiry` می‌نویسد. دفتر
دست‌نخورده می‌ماند تا «۳ جلسه‌ام چه شد؟» همیشه جواب داشته باشد.

---

## قفل بدبینانه اینجا، سطل زمانی آنجا

مصرف اعتبار با `PESSIMISTIC_WRITE` روی همان یک ردیف پکیج قفل می‌شود — برخلاف رزرو
اسلات (تسک ۰۷) که با سطل‌های پنج‌دقیقه‌ای و کلید یکتا کار می‌کند. این تفاوت عمدی است:

| | رزرو اسلات (تسک ۰۷) | اعتبار جلسه (تسک ۱۱) |
|---|---|---|
| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج |
| ردیف‌های درگیر | ده‌ها سطل | یک ردیف |
| هزینهٔ قفل | صف‌شدن رزروها | ناچیز |
| راه‌حل | کلید یکتا روی سطل | قفل بدبینانه |

اگر روزی کسی خواست «برای یکدستی» یکی را به دیگری تبدیل کند، همین جدول جواب است.

---

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

| جدول | وضعیت |
|---|---|
| `packages` · `patient_packages` · `session_credit_ledger` | جفت محیط |
| `package_services` | `AGGREGATE_CHILDREN` — ریشه `Package` |

برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، اعتبار جلسه جفت
محیط واقعی می‌گیرد: اعتبار جلسهٔ لیزر در کلینیک الف در کلینیک ب معنا ندارد.

## صفحه‌های پنل

| مسیر | صفحه |
|---|---|
| `/admin/packages` | تعریف پکیج‌ها |
| `/admin/patient-package/{uuid}/ledger` | دفتر اعتبار یک پکیج خریداری‌شده |

## تست‌ها

```bash
ddev exec php bin/phpunit tests/Package   # ۱۶ تست
```

مهم‌ترینش `testNoStoredBalanceColumnExists` است: هیچ ستون مانده‌ای در schema نباید
باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی»
می‌ایستد.
