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

## ۱. `quote` نمایش می‌دهد، `confirm` مصرف می‌کند

بدترین باگ ممکن در این تسک:

```php
// ❌ بیمار صفحه را سه بار رفرش می‌کند، سه جلسه از دست می‌دهد
public function quote(QuoteRequest $req): PriceQuote {
    $this->packages->consume(…);
}
```

```php
// ✅
public function quote(…): PriceQuote {
    $pkg = $this->finder->firstUsable(…);
    return $quote->withPackagePreview($pkg);   // فقط نمایش
}
// و در BookingService::confirm() داخل تراکنش:
$this->packages->consume($pkg, $appointment);
```

تست اجباری: ده بار `quote` → مانده بدون تغییر.

## ۲. مانده صفر خطا نیست

```php
if ($this->ledger->balance($pkg) <= 0) {
    return false;      // ✅ مبلغ کامل محاسبه می‌شود
    // نه: throw new AppException(...)
}
```

بیمار با پکیج تمام‌شده باید بتواند نقدی نوبت بگیرد. `422` یعنی بن‌بست بی‌دلیل.
UI پیام بدهد: «اعتبار پکیج شما تمام شده؛ این نوبت نقدی محاسبه می‌شود.»

## ۳. `uniq_scl_consume` و idempotency

`confirm` تسک ۰۷ idempotent است. اگر دوبار صدا زده شود:

```php
try {
    $this->ledger->record($pkg, KIND_CONSUME, -1, $meta);
} catch (UniqueConstraintViolationException) {
    // قبلاً مصرف شده — همان رفتار idempotent، نه خطا
}
```

با کلید یکتای `(appointment_id, kind)` این تضمین از دیتابیس می‌آید. همان الگوی تسک ۰۷.

## ۴. لغو = ردیف `refund`، نه حذف `consume`

```php
// ❌ تاریخ را پاک می‌کند
$this->em->remove($consumeRow);

// ✅
$this->ledger->record($pkg, KIND_REFUND, +1, LedgerMeta::forCancellation($appt));
```

دفتر append-only است. بعد از سه ماه، سؤال «چند بار این بیمار نوبتش را لغو کرد؟» فقط از
دفتر جواب دارد.

⚠️ بازگشت اعتبار **مشروط به سیاست لغو** است (تسک ۱۳). تا آن تسک نیامده، همیشه برگردان و
یک `TODO` با ارجاع به تسک ۱۳ بگذار — نه یک پرچم نیم‌کاره.

## ۵. FIFO و انقضا

```php
->orderBy('pp.purchasedAt', 'ASC')
```

قدیمی‌ترین اول. اگر LIFO باشد، پکیج قدیمی منقضی می‌شود و بیمار پولش را از دست می‌دهد —
و شکایتش درست است.

`valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود (`purchased_at + validity_days * 86400`)،
نه در زمان اجرا: تغییر `validity_days` تعریف پکیج نباید اعتبار خریدهای قبلی را عوض کند.

## ۶. قفل بدبینانه اینجا درست است

برخلاف تسک ۰۷ که قفل را رد کردیم:

```php
$locked = $this->em->find(PatientPackage::class, $id, LockMode::PESSIMISTIC_WRITE);
```

نرخ رقابت اینجا ناچیز است (یک بیمار، یک پکیج) و یک ردیف قفل می‌شود، نه ده‌ها سطل.
جدول مقایسه در `architecture.md` را در `docs/api/package.md` هم بنویس، وگرنه کسی روزی
«برای یکدستی» یکی را به دیگری تبدیل می‌کند.

## ۷. `adjustment` فقط با نقش مدیر و با دلیل

```php
#[IsGranted('ROLE_CLINIC_OWNER')]     // نه منشی، نه پرسنل
public function adjust(string $uuid, Request $request): JsonResponse
{
    $reason = trim((string) $data['reason'] ?? '');
    if ($reason === '') {
        return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ذکر دلیل اصلاح الزامی است', 422, 'reason');
    }
}
```

اصلاح دستی بدون دلیل، دفتر را به همان شمارندهٔ غیرقابل‌ردیابی تبدیل می‌کند که مستند
هشدار داده.

## ۸. edge case ها

| حالت | رفتار درست |
|---|---|
| بیمار دو پکیج معتبر برای یک سرویس | FIFO — قدیمی‌ترِ منقضی‌نشده |
| پکیج معتبر ولی سرویس نوبت پوشش داده نمی‌شود | اعمال نمی‌شود، مبلغ کامل |
| پکیج منقضی با مانده ۳ | ردیف `expiry -3` توسط cron؛ مانده صفر، دفتر کامل |
| `confirm` دوباره | `uniq_scl_consume` → idempotent |
| لغو نوبتی که پکیج نداشت | هیچ ردیفی ثبت نمی‌شود |
| `delta = 0` | `422` — ردیف بی‌اثر ننویس |
| حذف تعریف پکیجی که فروخته شده | `422` (FK RESTRICT) — `active=false` مسیر درست |
| پکیج بدون سرویس | `422` هنگام ساخت |
| مبلغ پکیج بزرگ‌تر از سقف INT | `BIGINT` — از قبل حل شده |
| بیمار مهمان بدون `patient_record` | پکیج فروش نمی‌رود — `422` با پیام «ابتدا پروندهٔ بیمار را ثبت کنید» |

## ۹. تست

```
tests/Package/CreditLedgerTest.php               ← ⭐
  - مانده = SUM(delta) در همهٔ سناریوها
  - purchase → consume → refund → مانده اولیه
  - append-only: هیچ remove/update روی ردیف‌ها
tests/Package/LedgerSchemaTest.php               ← ⭐
  - هیچ ستون remaining/used_count در schema
tests/Package/QuoteDoesNotConsumeTest.php        ← ⭐
  - ده بار quote → مانده بدون تغییر
tests/Package/ConcurrentConsumeTest.php
  - دو نوبت هم‌زمان روی آخرین اعتبار → یکی می‌گیرد، مانده منفی نمی‌شود
tests/Package/IdempotentConsumeTest.php
  - confirm دوبار → یک ردیف consume
tests/Package/FifoTest.php
  - قدیمی‌ترین پکیج اول مصرف می‌شود
tests/Package/ExpiryTest.php
  - cron ردیف expiry با delta = -balance می‌سازد
  - پکیج منقضی در finder نمی‌آید
tests/Package/AdjustmentAuthTest.php
  - منشی → 403 · مدیر بدون دلیل → 422 · مدیر با دلیل → 200
tests/Package/PackageTenantTest.php
  - پکیج محیط دیگر → 404
tests/Package/PricingIntegrationTest.php
  - ردیف package در price_snapshot_lines با مبلغ منفی
  - جمع ردیف‌ها = مبلغ نهایی (invariant تسک ۰۸ حفظ شود)
```

## ۱۰. مستندات

`docs/api/package.md` بساز. `docs/architecture/tenancy.md` را با دلیل تفاوت
«دفتر اعتبار (جفت tenant)» و «کیف پول (سراسری + انتساب)» به‌روز کن —
این دو شبیه‌اند و اشتباه گرفتنشان نشتی مالی می‌سازد.
