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

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

```
src/Package/
├── Entity/
│   ├── Package.php               # تعریف
│   ├── PackageService.php        # سرویس‌های پوشش‌داده‌شده (ManyToMany با تعداد)
│   ├── PatientPackage.php        # نمونهٔ خریداری‌شده
│   └── SessionCreditLedger.php   # دفتر
├── Service/
│   ├── PackageSalesService.php   # فروش
│   ├── CreditLedgerService.php   # ← تنها نویسندهٔ دفتر
│   └── PackageConsumptionService.php  # مصرف در زنجیرهٔ قیمت
├── Repository/…
└── Controller/{PackageController, PatientPackageController}.php
```

## دفتر، نه شمارنده

```php
final class CreditLedgerService
{
    public const KIND_PURCHASE   = 'purchase';    // + خرید
    public const KIND_CONSUME    = 'consume';     // − مصرف در نوبت
    public const KIND_REFUND     = 'refund';      // + بازگشت با لغو
    public const KIND_ADJUSTMENT  = 'adjustment'; // ± اصلاح دستی
    public const KIND_EXPIRY     = 'expiry';      // − ابطال

    /** مانده = جمع همهٔ delta ها. هیچ ستون ذخیره‌شده‌ای نیست. */
    public function balance(PatientPackage $pkg, ?ServiceItem $service = null): int
    {
        return $this->ledgerRepo->sumDelta($pkg, $service);
    }

    /** هیچ‌جای دیگری نباید در session_credit_ledger بنویسد. */
    public function record(PatientPackage $pkg, string $kind, int $delta, LedgerMeta $meta): SessionCreditLedger;
}
```

مستند: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل ردیابی نیست.» پس:

- **هیچ ستون `remaining` یا `used_count` در هیچ جدولی نیست** — تست schema این را اجبار کند
- هر تغییر یک ردیف است، با `reason` و `created_by` و ارجاع به نوبت
- تصحیح خطا = ردیف `adjustment` جدید، نه ویرایش ردیف قبلی

### هزینهٔ کارایی و پاسخش

`SUM(delta)` per بیمار per پکیج. تعداد ردیف‌ها کوچک است (پکیج ۸ جلسه‌ای ≤ ۲۰ ردیف).
اگر روزی لازم شد، **کش** بگذار، نه ستون:

```php
// cp:pkg:{patientPackageId}:balance   TTL 60s، ابطال روی هر record()
```

ستون denormalized یعنی دو منبع حقیقت و همان مشکلی که مستند هشدار داده.

## جلوگیری از منفی شدن مانده

دو نوبت هم‌زمان که هر دو آخرین اعتبار را می‌خواهند:

```php
public function consume(PatientPackage $pkg, Appointment $appt): bool
{
    // قفل بدبینانه روی خودِ ردیف پکیج — تعداد رقابت‌ها ناچیز است
    $locked = $this->em->find(PatientPackage::class, $pkg->getId(), LockMode::PESSIMISTIC_WRITE);

    if ($this->ledger->balance($locked) <= 0) {
        return false;   // ← خطا نیست؛ مبلغ کامل محاسبه می‌شود
    }
    $this->ledger->record($locked, KIND_CONSUME, -1, LedgerMeta::forAppointment($appt));
    return true;
}
```

اینجا **قفل بدبینانه درست است**، برخلاف تسک ۰۷:

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

پس راه‌حل متفاوت است و این تفاوت باید مستند شود، وگرنه کسی «برای یکدستی» یکی را
به دیگری تبدیل می‌کند.

## اتصال به زنجیرهٔ قیمت

قلاب مرحلهٔ ۴ تسک ۰۸ که تا حالا no-op بود:

```php
// PackageConsumptionService::consume(array $lines, QuoteRequest $req): array
$pkg = $this->finder->firstUsable($req->patient, $req->service, $req->at);   // FIFO
if ($pkg === null) return $lines;

// در quote فقط نمایش می‌دهیم، در confirm واقعاً کسر می‌کنیم
$lines[] = PriceLine::package($pkg, -$this->coveredAmount($lines, $pkg));
return $lines;
```

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

`PriceQuote` یک پرچم `packageWillBeConsumed` می‌گیرد تا UI بگوید «۱ جلسه از پکیج شما
کسر می‌شود».

## FIFO

```php
// PackageFinder::firstUsable()
// قدیمی‌ترین پکیج منقضی‌نشده با مانده > 0
$qb->orderBy('pp.purchasedAt', 'ASC')
   ->andWhere('pp.validTo IS NULL OR pp.validTo >= :now');
```

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

## انقضا

```bash
ddev exec php bin/console app:package:expire   # روزانه با symfony/scheduler
```

برای هر `PatientPackage` با `valid_to` گذشته و مانده > ۰:
یک ردیف `expiry` با `delta = -balance` ثبت می‌شود. دفتر دست‌نخورده می‌ماند و
تاریخچه کامل است — بیمار می‌تواند بپرسد «۳ جلسه‌ام چه شد؟» و جواب در دفتر است.

## پنل ادمین

- `PackagesPage.tsx` — تعریف پکیج‌ها با `PriceInput` و انتخاب سرویس‌ها
- در `PatientDetailPage.tsx` کارت «پکیج‌ها»: هر پکیج با مانده، تاریخ انقضا و لینک دفتر
- `PatientPackageLedgerPage.tsx` — جدول دفتر با ستون‌های: تاریخ، نوع، تغییر، مانده تجمعی،
  دلیل، ثبت‌کننده، نوبت مرتبط
- «مانده تجمعی» ستون محاسبه‌شده در UI است، نه ستون DB — و همین به کاربر ثابت می‌کند
  عدد از کجا آمده
