# بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات

## پروژه

`clinicpro` (بک‌اند Symfony + پنل ادمین React). تغییرات فقط داخل همین ریپو است؛ کلاینت بیرونی ندارد
(`nobat724_front` و `clinic-pro-tauri` هیچ‌کدام `/api/v1/billing/tenant-insurances` یا
`/api/v1/billing/claims/*` را مصرف نمی‌کنند — قبل از شروع با grep تأیید کن).

## زمینه

مدل بیمهٔ tenant امروز این است:

- کاتالوگ بیمه: `insurances` (`type` = `basic` | `supplementary`) — ۶ بیمهٔ پایه و ۱۰ بیمهٔ تکمیلی seed شده‌اند.
- قرارداد tenant: `TenantInsurance` (`tenant_insurances`) با `coverage_percent`، `franchise_rials`،
  `annual_ceiling_rials`، `kind`.
- درصد پوشش به تفکیک نوع خدمت: `TenantInsuranceCategoryCoverage` (override قرارداد) →
  `InsuranceCoverageDefault` (پیش‌فرض مرکزی ادمین) → `coverage_percent` قرارداد (fallback قدیمی).
- نوع خدمت: enum `ServiceCategory` = `outpatient` (خدمات سرپایی) / `inpatient` (خدمات بستری).
- «کدام نوع خدمت اصلاً بیمه‌ای است» یک سوییچ سراسریِ tenant است: `TenantServiceCategorySetting`
  (`tenant_service_category_settings`, ردیف نداشته = فعال) و از `GET /api/v1/insurance-pricing`
  در کلید `service_categories` بیرون می‌آید.
- محاسبهٔ سهم: `BillingCalculator::calculateItem()` روی `CoverageRule` — فرانشیز فقط در قرارداد تکمیلی
  اثر دارد و به‌صورت **مبلغ ریالی** به سهم بیمار اضافه می‌شود.
- مطالبات: `claims` با `insurance_id` و `insurance_kind` (`base` | `supplementary` — دقت کن، واژگان
  مطالبه `base` است نه `basic`). داشبورد سطح‌اول `ClaimsPage` یک ردیف به‌ازای هر **بیمار** می‌دهد.

سه ایراد واقعی در همین مسیر وجود دارد:

1. مودال «افزودن بیمه» ورودی «درصد پوشش» را برای **هر دو** نوع خدمت رندر می‌کند، بی‌توجه به این‌که
   tenant کدام نوع را فعال کرده، و خالی‌ماندنِ آن‌ها هیچ خطایی نمی‌دهد → قرارداد با پوشش صفر ثبت می‌شود.
2. فرانشیز به‌صورت «تومان» گرفته و ذخیره می‌شود (`franchise_rials`) در حالی که فرانشیز در بیمهٔ تکمیلی
   یک **درصد** است.
3. در داشبورد مطالبات (سطح اول) هیچ‌جا معلوم نیست هر ردیف مربوط به کدام بیمه است، و نه فیلتر «نوع بیمه»
   دارد و نه جستجو روی نام بیمه کار می‌کند. (سطح دومِ `ClaimPatientDetailPage` نام و نوع بیمه را دارد.)

## مشکل / هدف

۱. **درصد پوشش فقط برای نوع خدمتِ فعال، و الزامی.** در مودال افزودن/ویرایش بیمه، به‌جای «همهٔ
   ServiceCategoryها»، فقط نوع‌های خدمتی رندر شوند که برای همان tenant فعال‌اند
   (`service_categories[].enabled === true` از `GET /api/v1/insurance-pricing`)، و ثبت فرم بدون
   درصدِ معتبر (۱ تا ۱۰۰) برای هر نوع خدمتِ رندرشده مجاز نباشد. بک‌اند هم همین را اجبار کند
   (نه فقط UI).

۲. **فرانشیز درصد است، نه تومان.** `franchise_rials` → `franchise_percent` در هر دو سطح
   (قرارداد `TenantInsurance` و override خدمت `TenantServiceCoverage`)، در `CoverageRule`،
   در `BillingCalculator`، در API، در پنل، و در آینهٔ سمت‌کلاینت `assets/admin/lib/insuranceShares.ts`.

   **معنای انتخاب‌شده (این را در کد کامنت کن):** فرانشیز درصدی است از مبلغی که بیمهٔ تکمیلی روی آن
   کار می‌کند — یعنی «باقیماندهٔ بعد از بیمهٔ پایه» — و سهم بیمار است:

   ```
   remainingAfterBase = total − baseShare(سقف‌خورده)
   suppShare          = remainingAfterBase × coveragePercent٪ (سقف‌خورده)
   franchiseAmount    = remainingAfterBase × franchisePercent٪      ← جدید
   patient            = min(total, remainingAfterBase − suppShare + franchiseAmount)
   ```

   این با واقعیت بازار می‌خواند («تکمیلی ۹۰٪ می‌دهد، فرانشیز ۱۰٪») و ساختار فعلی فرمول را حفظ می‌کند؛
   تنها تفاوت این است که مبلغ فرانشیز به‌جای عدد ثابت، از درصد ساخته می‌شود. فرانشیز در بیمهٔ **پایه**
   همچنان بی‌اثر است.

۳. **مطالبات: کدام بیمه.** در `ClaimsPage` (سطح اول) ستون «بیمه» اضافه شود (یک بیمار می‌تواند مطالبه
   زیر چند بیمه داشته باشد → لیست بیمه‌های آن بیمار با نوعشان)، فیلتر «نوع بیمه» (پایه/تکمیلی) اضافه
   شود، و جستجوی متنی علاوه بر نام/موبایل/کد ملی بیمار، **نام بیمه** را هم پوشش دهد.

۴. **سناریوی واقعی + تست.** برای پزشک `09389388131` (کاربر `users.id = 11685`، پزشک
   `doctors.id = 11548`، `entity_type='doctor'`, `entity_id=11548`) چند بیمهٔ تکمیلی و چند بیمار
   ساخته شود که از این بیمه‌ها استفاده می‌کنند، مطالبات تولید و مسیر وضعیت‌ها تست شود.

## معیار پذیرش

**درصد پوشش / نوع خدمت فعال**

- ✅ موفق: tenantـی که فقط `outpatient` را فعال دارد، مودال «افزودن بیمه» تنها یک ورودی
  «درصد پوشش — خدمات سرپایی» نشان می‌دهد؛ با مقدار ۷۰ ثبت می‌شود و
  `GET /api/v1/billing/tenant-insurances` برای آن قرارداد `category_coverages.outpatient = 70` می‌دهد.
- ❌ خطا: `POST /api/v1/billing/tenant-insurances` با
  `category_coverages: [{key:'outpatient', coverage_percent: null}]` در حالی که `outpatient` فعال است
  → HTTP 422 با `{ success:false, errors:[{ code:'ERR_VALIDATION_001', field:'category_coverages', message:'درصد پوشش خدمات سرپایی الزامی است' }] }`.
  دکمهٔ «ثبت بیمه» هم در UI تا پرشدن همهٔ درصدهای فعال غیرفعال است.
- ⚠️ مرزی: نوع خدمتی که در تنظیمات tenant **غیرفعال** است نه رندر می‌شود و نه در payload می‌رود؛
  قراردادی که از قبل برای آن نوع override داشت، آن override دست‌نخورده در DB می‌ماند
  (ارسال‌نشدن ≠ حذف؛ حذف فقط با `coverage_percent: null` صریح انجام می‌شود).

**فرانشیز درصدی**

- ✅ موفق: قرارداد تکمیلی با `coverage_percent = 90` و `franchise_percent = 10` روی خدمتی
  ۱٬۰۰۰٬۰۰۰ ریالی بدون بیمهٔ پایه → `supplementary = 900,000`، `patient = 100,000 + 100,000 = 200,000`،
  و `total = base + supp + patient` همچنان برقرار است (تست موجود
  `tests/Patient/SessionInsuranceShareTest::testTheBreakdownAlwaysSumsBackToTheGrossTotal` باید سبز بماند).
- ❌ خطا: `franchise_percent: 150` → HTTP 422 «فرانشیز باید بین ۰ تا ۱۰۰ باشد»
  (`field: 'franchise_percent'`). همچنین `franchise_percent` منفی → همان خطا.
- ⚠️ مرزی: `franchise_percent = 100` روی قرارداد تکمیلی → سهم بیمار از `total` بیشتر نمی‌شود
  (`min(total)` نگه‌دارنده است) و سهم بیمه منفی نمی‌شود؛ در بیمهٔ **پایه** هر مقدار فرانشیز بی‌اثر است.

**مطالبات**

- ✅ موفق: `GET /api/v1/billing/claims/by-patient` برای بیماری با دو مطالبه زیر دو بیمه، آرایهٔ
  `insurances: [{insurance_id, insurance_name, kind}, …]` می‌دهد و ستون «بیمه» در جدول هر دو را
  نشان می‌دهد.
- ❌ خطا: `?kind=bogus` → HTTP 422 «نوع بیمه نامعتبر است» (نه ۵۰۰ و نه لیست خالیِ بی‌صدا).
- ⚠️ مرزی: `?search=آسیا` فقط بیمارانی را برمی‌گرداند که مطالبه‌ای زیر «بیمه آسیا» دارند، و
  `meta.totalRecords` با همان فیلتر هم‌خوان است (شمارش و لیست از یک WHERE می‌آیند)؛
  `?kind=supplementary&insurance_id=<یک بیمهٔ پایه>` → صفر ردیف، بدون خطا.

**سناریو**

- ✅ موفق: بعد از اجرای دستور سناریو، ورود به پنل با `09389388131` → صفحهٔ «پرونده‌های بیمه»
  حداقل ۴ بیمار با مطالبات زیر ۳ بیمهٔ تکمیلی مختلف نشان می‌دهد؛ کارت‌های آمار (ادعا/وصول/مانده)
  با جمع ستون‌های جدول هم‌خوان‌اند.
- ⚠️ مرزی: اجرای دوبارهٔ دستور، داده را دوباره نمی‌سازد (idempotent) یا با `--purge` تمیز و بازسازی می‌کند.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `clinicpro/src/Insurance/Entity/TenantInsurance.php` | ستون `franchise_rials` → `franchise_percent` |
| `clinicpro/src/Insurance/Entity/TenantServiceCoverage.php` | همان تغییر در override سطح خدمت |
| `clinicpro/src/Insurance/ValueObject/CoverageRule.php` | `franchiseRials` → `franchisePercent` |
| `clinicpro/src/Insurance/Service/TenantInsuranceService.php` | `activate()`، `buildRule()`، `setServiceCoverage()`، اعتبارسنجی درصد |
| `clinicpro/src/Insurance/Controller/InsuranceController.php` | خواندن/نوشتن `franchise_percent`، اجبار درصد برای نوع خدمت فعال (خطوط ۴۷۶–۵۹۲ و ۶۴۷–۶۹۳) |
| `clinicpro/src/Billing/Service/BillingCalculator.php` | فرمول فرانشیز درصدی |
| `clinicpro/src/Billing/Repository/ClaimRepository.php` | `patientAggregateSql()` + `patientAggregateFilters()` — بیمه‌ها، فیلتر `kind`، جستجوی نام بیمه |
| `clinicpro/src/Billing/Controller/BillingController.php` | `claimsByPatient()`، `claimFilters()` — پارامتر `kind` و نگاشت نام بیمه |
| `clinicpro/assets/admin/components/InsuranceModal.tsx` | ورودی فرانشیز درصدی + درصد الزامی + فقط نوع خدمت فعال |
| `clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` | تغذیهٔ مودال با نوع خدمتِ فعال، خلاصه و جزئیات فرانشیز درصدی |
| `clinicpro/assets/admin/lib/insuranceShares.ts` | آینهٔ فرمول سمت کلاینت |
| `clinicpro/assets/admin/components/session/CreateStep.tsx` | مصرف‌کنندهٔ `franchise_rials` در پذیرش |
| `clinicpro/assets/admin/components/ServiceInsuranceModal.tsx`، `pages/ServiceDetailPage.tsx` | override فرانشیز سطح خدمت |
| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | ستون بیمه + فیلتر نوع بیمه |
| `clinicpro/assets/admin/types/index.ts` | `ClaimPatientRow` |
| `clinicpro/migrations/` | migration تغییر نام و نوع ستون‌ها |
| `clinicpro/docs/api/insurance.md`، `docs/api/billing.md` | مستندسازی قرارداد جدید |

## وضعیت فعلی

`clinicpro/src/Billing/Service/BillingCalculator.php` (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت:

```php
        $suppShare = Money::zero();
        if ($supplementary !== null && $supplementary->covered) {
            $suppShare = $remaining->percent($supplementary->coveragePercent);
            if ($supplementary->ceilingRials !== null) {
                $suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
            }
            $remaining = $remaining->sub($suppShare);
        }

        // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ
        // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمی‌کند.
        $franchise = new Money($supplementary?->franchiseRials ?? 0);
        $patient   = $remaining->add($franchise)->min($total);
```

`clinicpro/assets/admin/components/InsuranceModal.tsx` (خطوط ۲۱۹–۲۵۵) — همهٔ categoryها رندر می‌شوند،
هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است:

```tsx
        <div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, categories.length)}, 1fr)`, gap: 12 }}>
          {categories.map((c) => (
            <div key={c.key} style={field}>
              <label style={label}>درصد پوشش — {c.label}</label>
              <input … value={form.categoryPercents[c.key] ?? ''} onChange={(e) => setPercent(c.key, e.target.value)} />
            …
        {!isBasic && (
            <div style={field}>
              <label style={label}>فرانشیز (تومان)</label>
              <input … value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
            </div>
        )}
```

و در `buildInsurancePayload()`:

```ts
    franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
```

`clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` — مودال از لیست سراسری تغذیه می‌شود
و فرانشیز ریالی نمایش داده می‌شود:

```tsx
  const { categories } = useServiceCategories();          // همهٔ نوع‌ها، نه نوع‌های فعالِ tenant
  …
  if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) {
    parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
  }
```

`clinicpro/src/Billing/Repository/ClaimRepository.php` — نه بیمه‌ای در SELECT سطح بیمار هست و نه
جستجو/فیلتر نوع بیمه:

```php
        if (!empty($filters['search'])) {
            $conditions[]     = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
            $params['search'] = '%' . trim((string) $filters['search']) . '%';
        }
```

## وظایف

### ۱. مهاجرت داده: `franchise_rials` → `franchise_percent`

`TenantInsurance`:

```php
    #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)]
    private string $franchisePercent = '0.00';

    public function getFranchisePercent(): float        { return (float) $this->franchisePercent; }
    public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; }
```

`TenantServiceCoverage`: همان تغییر با `nullable: true` و getter/setter `?float`.
هر دو `toArray()` کلید `franchise_percent` بدهند (کلید `franchise_rials` کاملاً حذف شود — سازگاری
عقب‌رو لازم نیست چون تنها مصرف‌کننده پنل خودِ همین ریپوست).

Migration دستی بنویس (نه فقط `diff`)، چون تبدیل ریال به درصد معنا ندارد:

```php
$this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent DECIMAL(5,2) NOT NULL DEFAULT 0');
$this->addSql('UPDATE tenant_insurances SET franchise_percent = 0');
$this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent DECIMAL(5,2) DEFAULT NULL');
$this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL');
```

در `getDescription()` صریح بنویس که مقادیر ریالیِ قبلی قابل تبدیل نیستند و صفر می‌شوند.

**نحوه تست:** `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` سپس
`ddev exec php bin/console doctrine:schema:validate` باید mapping را سبز بدهد.

### ۲. فرمول فرانشیز درصدی

`CoverageRule`:

```php
final readonly class CoverageRule
{
    public function __construct(
        public float $coveragePercent,
        /** درصدِ سهم بیمار از «باقیماندهٔ بعد از بیمهٔ پایه»؛ فقط در قرارداد تکمیلی معنا دارد. */
        public float $franchisePercent,
        public ?int  $ceilingRials,
        public bool  $covered = true,
    ) {}

    public static function notCovered(): self { return new self(0.0, 0.0, null, false); }
}
```

`BillingCalculator::calculateItem()` — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته می‌شود:

```php
        $suppBase  = $remaining;                       // باقیماندهٔ بعد از بیمهٔ پایه
        $suppShare = Money::zero();
        if ($supplementary !== null && $supplementary->covered) {
            $suppShare = $suppBase->percent($supplementary->coveragePercent);
            if ($supplementary->ceilingRials !== null) {
                $suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
            }
            $remaining = $suppBase->sub($suppShare);
        }

        // فرانشیز درصدی است، نه مبلغ: سهمِ بیمار از همان مبلغی که تکمیلی رویش کار می‌کند.
        $franchise = $supplementary !== null && $supplementary->covered
            ? $suppBase->percent($supplementary->franchisePercent)
            : Money::zero();
        $patient = $remaining->add($franchise)->min($total);
```

`TenantInsuranceService::buildRule()` و `activate()` و `setServiceCoverage()` را به
`franchisePercent`/`franchise_percent` ببر و اعتبارسنجی ۰..۱۰۰ را (همان الگوی
`setCategoryCoverages()`) اضافه کن:

```php
        if ($franchisePercent < 0 || $franchisePercent > 100) {
            throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent');
        }
```

`InsuranceController` هر سه نقطهٔ `franchise_rials` (خطوط ۵۰۲، ۵۶۴–۵۶۵، ۶۸۵) را با
`franchise_percent` و cast به `float` جایگزین کند.

**نحوه تست:** یک تست واحد در `tests/Billing/` برای `BillingCalculator` با سه سناریو:
(الف) پایه ۳۰٪ + تکمیلی ۹۰٪ + فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ ریال، (ب) فرانشیز ۱۰۰٪،
(ج) بیمهٔ پایه با فرانشیز غیرصفر (باید بی‌اثر بماند). در هر سه،
`total === base + supp + patient` را assert کن. علاوه بر آن:
`ddev exec php bin/phpunit tests/Patient/SessionInsuranceShareTest.php` باید سبز بماند
(مقادیر انتظاری‌اش را طبق فرمول جدید به‌روز کن، نه با تغییر فرمول).

### ۳. الزامی‌کردن درصد پوشش برای نوع خدمتِ فعال

**بک‌اند** — در `InsuranceController::applyCategoryCoverages()` قبل از فراخوانی سرویس، بررسی کن که
برای هر نوع خدمتِ فعالِ همان tenant یک درصد معتبر آمده باشد:

```php
        $enabled = $this->serviceCategories->enabledKeys($entityType, $entityId);
        $sent    = [];
        foreach ($data['category_coverages'] ?? [] as $row) {
            $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null;
        }
        foreach ($enabled as $key) {
            if (($sent[$key] ?? null) === null || $sent[$key] === '' || (float) $sent[$key] <= 0) {
                return $this->error(
                    ErrorCodes::ERR_VALIDATION_001,
                    sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()),
                    422,
                    'category_coverages',
                );
            }
        }
```

توجه: `applyCategoryCoverages()` امروز `$entityType/$entityId` ندارد — امضایش را گسترش بده
(هر دو فراخوان `activate` و `update` این مقادیر را در دست دارند). این چک فقط وقتی اجرا شود که
کلید `category_coverages` در payload آمده باشد؛ PATCHـهای دیگر (مثل toggle وضعیت) نباید بشکنند.

**فرانت** — `TenantInsuranceContracts.tsx` به‌جای `useServiceCategories()` نوع خدمتِ **فعالِ tenant**
را از همان `pricingQuery` که از قبل fetch می‌شود بگیرد (بدون درخواست جدید):

```tsx
  const enabledCategories: ServiceCategoryOption[] = useMemo(() => (
    ((pricingQuery.data as any)?.data?.service_categories ?? [])
      .filter((c: { enabled: boolean }) => c.enabled)
      .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label }))
  ), [pricingQuery.data]);
```

و همین را به `categories` مودال بدهد. `useServiceCategories` اگر مصرف‌کنندهٔ دیگری ندارد دست‌نخورده
بماند (فرم خدمت از آن استفاده می‌کند — قبل از حذف grep کن).

`InsuranceModal.tsx`: دکمهٔ ثبت تا وقتی هر نوع خدمتِ رندرشده مقدار ۱..۱۰۰ نداشته باشد `disabled` بماند،
و زیر ورودیِ خالی پیام «الزامی است» با `var(--danger)` نشان داده شود:

```tsx
  const percentsValid = categories.every((c) => {
    const n = Number(form.categoryPercents[c.key]);
    return Number.isFinite(n) && n > 0 && n <= 100;
  });
  …
  disabled={!form.insuranceId || !percentsValid || isPending}
```

(وقتی `canUpdate === false` درصدها فقط‌خواندنی و ارسال‌نشدنی‌اند — در آن حالت این اجبار اعمال نشود.)

**نحوه تست:**
- API: با توکن پزشک `09389388131`، `PUT /api/v1/insurance-pricing` با
  `service_categories: [{key:'outpatient',enabled:true},{key:'inpatient',enabled:false}]`، سپس
  `POST /api/v1/billing/tenant-insurances` یک‌بار بدون درصد (انتظار ۴۲۲) و یک‌بار با
  `[{key:'outpatient',coverage_percent:70}]` (انتظار ۲۰۱).
- UI: `assets/admin/components/InsuranceModal.test.tsx` را با کیس «ثبت غیرفعال است تا درصد پر شود»
  و «فقط نوع خدمتِ فعال رندر می‌شود» گسترش بده؛ `yarn test`.

### ۴. فرانشیز درصدی در پنل

- `InsuranceModal.tsx`: برچسب «فرانشیز (درصد)»، ورودی `digitsOnly(value, 3)` با اعتبارسنجی ≤۱۰۰،
  `contractToForm()` از `c.franchise_percent` بخواند (بدون `rialToToman`)، و
  `buildInsurancePayload()` بفرستد:

  ```ts
    franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
  ```

  `Contract` و `InsuranceFormValues` هم به‌روز شوند (`franchise_percent: number`).
- `TenantInsuranceContracts.tsx`: `contractSummary()` → ``فرانشیز ${formatNumber(c.franchise_percent)}٪``
  و `ContractDetails` → مقدار `٪`دار.
- `insuranceShares.ts`: `CoverageRule.franchise` به معنی درصد شود و `patientShareOf()` دقیقاً آینهٔ
  فرمول جدید سرور باشد (پایهٔ فرانشیز = باقیماندهٔ بعد از پایه). این فایل تنها منبع محاسبه در پنل است؛
  هیچ صفحه‌ای فرمول موازی ننویسد.
- `CreateStep.tsx`، `ServiceInsuranceModal.tsx`، `ServiceDetailPage.tsx`: `franchise_rials` →
  `franchise_percent`؛ در `ServiceInsuranceModal` ورودی از `PriceInput` به ورودی درصد تغییر کند و
  در `ServiceDetailPage` نمایش `فرانشیز {formatRial(...)}` به `فرانشیز {formatNumber(...)}٪` تبدیل شود.

**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` (هیچ ارجاع باقیمانده‌ای به `franchise_rials`
نباید بماند — با grep هم تأیید کن) و `yarn test` (فایل‌های تستِ متأثر:
`InsuranceModal.test.tsx`, `TenantInsuranceContracts.test.tsx`, `session/CreateStep.test.tsx`,
`appointments/ConfirmAppointmentModal.test.tsx`, `appointments/TurnsTimeline.test.tsx`,
`pages/AppointmentEditPage.test.tsx`).

### ۵. بیمه در داشبورد مطالبات

**Repository** — در `patientAggregateSql()` و `detailsForPatient()` هر دو، join کاتالوگ اضافه شود
(برای جستجوی نام بیمه) و در نمای تجمیعی، بیمه‌های هر بیمار جمع شوند:

```sql
            LEFT JOIN insurances ins ON ins.id = c.insurance_id
            …
                GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, ins.name, c.insurance_kind) SEPARATOR '~') AS insurances,
```

سپس در `aggregateByPatient()` به آرایهٔ ساخت‌یافته تبدیل شود:

```php
            'insurances' => array_values(array_filter(array_map(static function (string $chunk): ?array {
                [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null);
                return $id === null || $id === '' ? null : [
                    'insurance_id'   => (int) $id,
                    'insurance_name' => $name,
                    'kind'           => $kind,   // base | supplementary
                ];
            }, explode('~', (string) $r['insurances'])))),
```

در `patientAggregateFilters()`:

```php
        if (!empty($filters['kind'])) {
            $conditions[]   = 'c.insurance_kind = :kind';
            $params['kind'] = $filters['kind'];
        }
        if (!empty($filters['search'])) {
            $conditions[]     = '(u.real_name LIKE :search OR u.mobile_number LIKE :search'
                . ' OR u.national_code LIKE :search OR ins.name LIKE :search)';
            $params['search'] = '%' . trim((string) $filters['search']) . '%';
        }
```

⚠️ همان WHERE در `countPatientsWithClaims()` و `detailsForPatient()` هم استفاده می‌شود؛ join جدید باید
در **هر سه** SQL باشد وگرنه شمارش با لیست واگرا می‌شود یا کوئری با «Unknown column ins.name» می‌شکند.
شرط محیطِ `c.entity_type = :type AND c.entity_id = :id` را دست نزن (کامنت هشدارِ همان فایل).

**Controller** — `claimFilters()` کلید `kind` را بگیرد و مقدارش را اعتبارسنجی کند
(`base` | `supplementary`، در غیر این صورت `ERR_VALIDATION_001` با ۴۲۲ و پیام «نوع بیمه نامعتبر است»).

**فرانت** — `ClaimsPage.tsx`:

```tsx
const KIND_OPTIONS = [
  { value: '', label: 'همه انواع' },
  { value: 'base', label: 'پایه' },
  { value: 'supplementary', label: 'تکمیلی' },
];
```

- فیلتر نوع بیمه با `SearchableSelect` (طبق قاعدهٔ پروژه؛ `<select>` بومی ممنوع) کنار فیلتر بیمه،
  با ذخیره در query string مثل بقیهٔ فیلترها.
- ستون جدید بین «بیمار» و «تعداد درخواست»:

  ```tsx
    { key: 'insurances', header: 'بیمه', render: (r) => (
        r.insurances.length === 0 ? '—' :
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
          {r.insurances.map((i) => (
            <span key={i.insurance_id} className="badge" title={KIND_TITLE[i.kind] ?? i.kind}>
              {i.insurance_name ?? `#${i.insurance_id}`}
            </span>
          ))}
        </div>
      ) },
  ```

- `searchPlaceholder` به «نام، موبایل، کد ملی بیمار یا نام بیمه» به‌روز شود.
- `ClaimPatientRow` در `types/index.ts` فیلد `insurances: { insurance_id: number; insurance_name: string | null; kind: string }[]` بگیرد.

**نحوه تست:** با توکن پزشک سناریو:
`GET /api/v1/billing/claims/by-patient?limit=50` → هر ردیف `insurances` غیرخالی؛
`?kind=supplementary` → فقط بیماران دارای مطالبهٔ تکمیلی؛ `?search=آسیا` → فقط بیماران آن بیمه؛
`?kind=bogus` → ۴۲۲. و مقایسهٔ `meta.totalRecords` با تعداد ردیف‌ها در حالت بدون صفحه‌بندی.

### ۶. سناریوی داده + تست دستی برای پزشک 09389388131

یک Console Command بساز: `src/Insurance/Command/SeedInsuranceScenarioCommand.php`
(`app:seed-insurance-scenario`)، با آپشن‌های `--doctor-mobile=09389388131` و `--purge`.

منطق (همه از مسیر سرویس‌های واقعی، نه INSERT خام — تا اعداد را همان `BillingCalculator` بسازد):

1. پزشک را از موبایل پیدا کن (`users.mobile_number` → `doctors.user_id`). نبود پزشک = خطای واضح.
   محیط: `entity_type='doctor'`, `entity_id = doctors.id` (برای این موبایل: `11548`).
2. هر دو نوع خدمت (`outpatient`, `inpatient`) را برای این tenant فعال کن
   (`TenantServiceCategoryService::save()`).
3. یک بیمهٔ پایه (`تامین اجتماعی`, id 176) + سه بیمهٔ تکمیلی از کاتالوگ فعال کن — مثلاً
   `بیمه ایران` (182)، `بیمه آسیا` (183)، `بیمه دی` (184) — با
   `TenantInsuranceService::activate()` و درصدهای متفاوت، و برای هر کدام
   `setCategoryCoverages()` با درصد سرپایی و بستریِ متفاوت:

   | بیمه | نوع | سرپایی | بستری | فرانشیز | سقف سالانه |
   |---|---|---|---|---|---|
   | تامین اجتماعی | پایه | ۳۰٪ | ۴۰٪ | — | نامحدود |
   | بیمه ایران | تکمیلی | ۹۰٪ | ۸۰٪ | ۱۰٪ | ۵۰٬۰۰۰٬۰۰۰ ریال |
   | بیمه آسیا | تکمیلی | ۷۰٪ | ۶۰٪ | ۲۰٪ | نامحدود |
   | بیمه دی | تکمیلی | ۱۰۰٪ | ۵۰٪ | ۰٪ | ۱۰٬۰۰۰٬۰۰۰ ریال |

4. چهار بیمار با موبایل نشان‌دار `091299000{1..4}` بساز (marker مخصوص همین سناریو تا `--purge`
   بتواند دقیقاً همان‌ها را پاک کند)، برای هرکدام `PatientRecord` در همین محیط، و مراجعه/صورتحساب
   با ترکیب‌های متفاوت: بیمار۱ فقط پایه، بیمار۲ پایه + بیمه ایران، بیمار۳ فقط بیمه آسیا،
   بیمار۴ پایه + بیمه دی با خدمتی که سقف را رد می‌کند (تست سقف).
5. صورتحساب‌ها را نهایی کن و با `ClaimService::createFromInvoice()` مطالبه بساز؛ سپس چند مطالبه را
   با `ClaimService::transition()` جلو ببر: یکی `submitted`، یکی `approved`، یکی `paid`،
   یکی `rejected` با دلیل — تا هر پنج وضعیتِ فیلتر داده داشته باشد.
6. در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.

**نحوه تست:**

```bash
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131   # بار دوم: بدون داده‌ی تکراری
```

سپس ورود به پنل با `09389388131` و بررسی دستی:
- `/admin/insurance-pricing`: تب «بیمه تکمیلی» سه قرارداد، هر کدام با «سرپایی X٪ · بستری Y٪ · فرانشیز Z٪ · سقف …»؛
  باز کردن مودال ویرایش → فرانشیز درصدی، و اگر یکی از نوع‌های خدمت را غیرفعال کنی آن ورودی محو شود.
- `/admin/claims`: ستون «بیمه» پر، فیلتر «نوع بیمه» کار کند، جستجوی «آسیا» فقط بیمار۳ را بدهد،
  کارت‌های آمار با جمع ستون‌ها هم‌خوان باشند.
- ورود به جزئیات یک بیمار: سهم‌ها با فرمول فرانشیز درصدی هم‌خوان باشند
  (`مبلغ اصلی − سهم بیمه = سهم بیمار`).

اسکرین‌شات هر دو صفحه را بعد از سناریو ضمیمه کن.

### ۷. مستندات

`docs/api/insurance.md` و `docs/api/billing.md` را در همین session به‌روز کن:
- تغییر فیلد `franchise_rials` → `franchise_percent` در
  `POST/PATCH /api/v1/billing/tenant-insurances` و
  `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` (با محدودهٔ ۰..۱۰۰).
- اجباری‌بودن `category_coverages` برای نوع خدمتِ فعال + خطای ۴۲۲ مربوطه.
- پارامتر جدید `kind` و رفتار تازهٔ `search` در `GET /api/v1/billing/claims/by-patient` و فیلد
  خروجی `insurances`.
- فرمول جدید محاسبهٔ سهم (همان بلوک شبه‌کد بالا) در بخش محاسبهٔ صورتحساب.

## نکات مهم

- **ترتیب اجرا:** اول migration + مدل + فرمول (وظایف ۱–۲)، بعد بک‌اندِ اعتبارسنجی (۳)، بعد فرانت (۴–۵)،
  و سناریو (۶) در آخر — سناریو باید روی کد نهایی اجرا شود تا اعدادش با فرمول جدید ساخته شوند.
- `franchise_rials` هیچ‌جا نباید باقی بماند: بعد از پایان کار
  `grep -rn "franchise_rials" src/ assets/ docs/ migrations/` فقط باید فایل migration جدید و
  migrationهای تاریخی `Version20260622*.php` را نشان بدهد.
- **قاعدهٔ فرانشیز فقط تکمیلی:** در `buildRule()` و در `insuranceShares.ts::ruleOf()` این شرط
  (`kind === 'supplementary'`) دست‌نخورده بماند؛ تغییر واحد از ریال به درصد است، نه تغییر دامنهٔ اثر.
- **دو واژگان جدا:** قرارداد `kind` = `basic`/`supplementary` ولی مطالبه `insurance_kind` =
  `base`/`supplementary`. در ClaimsPage از `base` استفاده کن (نه `basic`)؛ این تفاوت واقعی است
  و در `ClaimPatientDetailPage` هم همین‌طور نگاشت شده.
- **SQL خام و tenant:** کوئری‌های `ClaimRepository` زیر `TenantFilter` نیستند؛ افزودن join نباید
  شرط `c.entity_type/:type` را جابه‌جا یا مشروط کند.
- **الگوها:** کد جدید همان الگوی موجود را نگه دارد — controller نازک، منطق در Service، کوئری در
  Repository، پاسخ‌ها با `$this->success()/paginated()/error()`، تاریخ‌ها `int` یونیکس. الگوی طراحیِ
  تازه لازم نیست؛ `CoverageRule` همچنان همان Value Object است و فقط واحد یکی از فیلدهایش عوض می‌شود.
- **UI:** هیچ طراحی جدیدی نساز — از `Modal`، `SearchableSelect`، `DataTable`، `StatusBadge`، توکن‌های
  `styles.css` و کلاس‌های موجود (`badge`, `btn`, `input`, `field-label`) استفاده کن. رشته‌ها فارسی،
  اعداد با `formatNumber` و درصد با `٪`.
- **بدون تست سبز تمام نیست:** `ddev exec php bin/phpunit` و `yarn test` و
  `npx tsc --noEmit --project tsconfig.json` هر سه باید پاس شوند، و
  `ddev exec php vendor/bin/phpstan analyse` رگرسیون جدید ندهد.
