# اصلاح محاسبه بیمه پایه بر پایه «درصد پوشش» + تنظیمات مرکزی درصدها در پنل ادمین

## پروژه

`clinicpro` (بک‌اند Symfony + پنل React ادمین/پزشک). دامنهٔ اصلی: `src/Insurance/`، `src/Billing/`، `src/Patient/`، `assets/admin/`.
سایت عمومی `nobat724_front` هیچ‌جا `insurance-pricing` یا `patient_share_rials` را مصرف نمی‌کند (بررسی شد) → پرامپت همتا لازم نیست.

## زمینه

محاسبهٔ سهم بیمهٔ پایه در سیستم بر پایهٔ ترکیبی از «فرانشیز ریالی» و «سهم بیمار ثابت» انجام می‌شود و در نتیجه:

1. `BillingCalculator` فرانشیز ریالی قرارداد بیمهٔ پایه را به سهم بیمار اضافه می‌کند؛ در حالی‌که قاعدهٔ درست بیمهٔ پایه فقط درصدی است.
2. در `MyPatientsPage` درصد پوشش از روی «سهم بیمار ثابت» (`EntityInsurancePricing.patient_share_rials`) معکوس‌سازی می‌شود (`shareToDiscountPercent`) — یعنی درصد واقعی قرارداد نادیده گرفته می‌شود.
3. هیچ منبع مرکزی برای درصد پوشش بیمه‌های پایه وجود ندارد؛ هر پزشک/کلینیک درصد را دستی وارد می‌کند و بین tenantها اختلاف ایجاد می‌شود.
4. درصد پوشش، تک‌مقداری است؛ تفکیک «خدمات سرپایی / بستری» (و انواع آیندهٔ خدمت) وجود ندارد.

## هدف

الف) قاعدهٔ محاسبه در کل سیستم:

```
سهم بیمهٔ پایه = round(مبلغ کل × درصد پوشش پایه ÷ 100)
سهم بیمار      = مبلغ کل − سهم بیمهٔ پایه           (فرانشیز در بیمهٔ پایه دخالت ندارد)
```

مثال مرجع کاربر (مبالغ نمایش تومان، ذخیره ریال — ۱ تومان = ۱۰ ریال):

| مورد | تومان | ریال |
|---|---|---|
| هزینه ویزیت | 595,200 | 5,952,000 |
| درصد پوشش پایه (بستری) | ۳۰٪ | — |
| سهم بیمهٔ پایه | 178,560 | 1,785,600 |
| سهم بیمار | 416,640 | 4,166,400 |

ب) درصدهای پوشش هر بیمهٔ پایه به‌صورت **مرکزی در پنل ادمین اصلی** (نقش `ROLE_ADMIN`) تعریف شوند: حداقل «سرپایی» و «بستری»، با ساختار توسعه‌پذیر برای انواع بعدی.

ج) هنگام ایجاد/ویرایش قرارداد بیمه توسط پزشک یا کلینیک، این درصدها **پیش‌فرض بارگذاری** شوند؛ پزشک در حالت عادی چیزی وارد نکند و فقط در صورت داشتن مجوز بتواند برای همان قرارداد override کند.

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

| فایل | نقش |
|---|---|
| `src/Billing/Service/BillingCalculator.php` | محاسبهٔ سهم پایه/تکمیلی/بیمار — نقطهٔ مرکزی باگ فرانشیز |
| `src/Insurance/ValueObject/CoverageRule.php` | VO قاعدهٔ پوشش (`coveragePercent`, `franchiseRials`, `ceilingRials`) |
| `src/Insurance/Service/TenantInsuranceService.php` | ساخت `CoverageRule` از قرارداد tenant + override خدمت |
| `src/Insurance/Entity/Insurance.php` | کاتالوگ بیمه (ادمین) — محل اتصال درصدهای پیش‌فرض |
| `src/Insurance/Entity/TenantInsurance.php` | قرارداد بیمهٔ پزشک/کلینیک (`coverage_percent`, `franchise_rials`) |
| `src/Insurance/Entity/TenantServiceCoverage.php` | override پوشش در سطح خدمت |
| `src/Insurance/Entity/EntityInsurancePricing.php` | «سهم بیمار ثابت» هر بیمه (مدل قدیمی) |
| `src/Insurance/Controller/InsuranceController.php` | همهٔ اندپوینت‌های بیمه (ادمین + tenant + pricing) |
| `src/Insurance/Enum/InsuranceType.php` | `basic` / `supplementary` |
| `src/ClinicService/Entity/ServiceItem.php` | خدمت — **فاقد** نوع خدمت (سرپایی/بستری) |
| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()` + snapshot درصدها روی مراجعه |
| `src/Billing/Service/InvoiceService.php` | صدور فاکتور از مراجعه با همان CoverageRule |
| `assets/admin/components/InsuranceModal.tsx` | فرم افزودن/ویرایش قرارداد بیمه (پنل پزشک) |
| `assets/admin/components/TenantInsuranceContracts.tsx` | لیست/کارت قراردادهای بیمهٔ tenant |
| `assets/admin/components/ServiceInsuranceModal.tsx` | override پوشش یک خدمت |
| `assets/admin/components/session/CreateStep.tsx` | آینهٔ سمت‌کلاینت `BillingCalculator` (`patientShareOf`) |
| `assets/admin/pages/MyPatientsPage.tsx` | `shareToDiscountPercent` — منبع محاسبهٔ اشتباه |
| `assets/admin/pages/CategoriesPage.tsx` | تب «بیمه‌ها» در پنل ادمین اصلی (CRUD کاتالوگ بیمه) |
| `docs/api/insurance.md`, `docs/api/billing.md`, `docs/api/patient.md` | مستندات API |

## وضعیت فعلی

### ۱) فرانشیز به سهم بیمار اضافه می‌شود — `src/Billing/Service/BillingCalculator.php:15-52`

```php
if ($base !== null && $base->covered) {
    $baseShare = $total->percent($base->coveragePercent);
    if ($base->ceilingRials !== null) {
        $baseShare = $baseShare->min(new Money($base->ceilingRials));
    }
    $remaining = $total->sub($baseShare);
}
// ...
// فرانشیز سهم بیمار است؛ از سهم بیمه کم نمی‌کند ولی سهم بیمار از کل بیشتر نمی‌شود.
$franchise = new Money(
    ($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0)
);
$patient = $remaining->add($franchise)->min($total);
```

### ۲) درصد پوشش از «سهم بیمار ثابت» معکوس می‌شود — `assets/admin/pages/MyPatientsPage.tsx:397-421`

```tsx
// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. share=null یعنی پوشش ندارد (۰٪).
const shareToDiscountPercent = (insuranceId: string): number => {
    if (!insuranceId || freeVisitPrice <= 0) return 0;
    const ins = pricing?.insurances.find((i) => String(i.insurance_id) === insuranceId);
    if (!ins || ins.patient_share_rials == null) return 0;
    const covered = Math.max(0, freeVisitPrice - ins.patient_share_rials);
    return Math.round((covered / freeVisitPrice) * 1000) / 10;
};

const applyBaseInsurance = (insuranceId: string) => {
    // ...
    form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId));
};
```

### ۳) قرارداد فقط یک درصد دارد و پزشک باید دستی وارد کند — `assets/admin/components/InsuranceModal.tsx:169-182`

```tsx
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
  <div style={field}>
    <label style={label}>درصد پوشش</label>
    <input ... value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
  </div>
  <div style={field}>
    <label style={label}>فرانشیز (تومان)</label>
    <input ... value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
  </div>
  <div style={field}>
    <label style={label}>سقف تعهد (تومان)</label>
    ...
  </div>
</div>
```

### ۴) قاعدهٔ پوشش، بی‌خبر از نوع خدمت — `src/Insurance/Service/TenantInsuranceService.php:111-138`

```php
return new CoverageRule(
    coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
    franchiseRials:  $override?->getFranchiseRials()  ?? $contract->getFranchiseRials(),
    ceilingRials:    $override?->getCeilingRials()    ?? $contract->getAnnualCeilingRials(),
    covered:         true,
);
```

`ServiceItem` هیچ فیلدی برای «سرپایی/بستری» ندارد (فیلدها: `name`, `price_rials`, `active`, `insurance_covered`, `insurance_price_rials`, `duration_minutes`, `bookable`, `inventory_package_id`).

## وظایف

> ترتیب پیشنهادی: ۱ → ۹ (بک‌اند اول، سپس فرانت، سپس مستندات/تست). هر گام مستقل قابل تست باشد.

### ۱. Enum نوع خدمت (توسعه‌پذیر)

فایل جدید `src/Insurance/Enum/ServiceCategory.php`:

```php
namespace App\Insurance\Enum;

enum ServiceCategory: string
{
    case Outpatient = 'outpatient';   // سرپایی
    case Inpatient  = 'inpatient';    // بستری

    public function label(): string
    {
        return match ($this) {
            self::Outpatient => 'خدمات سرپایی',
            self::Inpatient  => 'خدمات بستری',
        };
    }

    /** @return list<string> */
    public static function values(): array
    {
        return array_map(static fn(self $c) => $c->value, self::cases());
    }
}
```

افزودن نوع جدید در آینده = فقط یک `case` تازه؛ هیچ جای دیگری نباید لیست ثابت hardcode شود (نه در Entity، نه در Controller، نه در فرانت — فرانت لیست را از API می‌گیرد).

### ۲. جدول درصدهای پیش‌فرض ادمین

Entity جدید `src/Insurance/Entity/InsuranceCoverageDefault.php` + `src/Insurance/Repository/InsuranceCoverageDefaultRepository.php`:

```php
#[ORM\Entity(repositoryClass: InsuranceCoverageDefaultRepository::class)]
#[ORM\Table(name: 'insurance_coverage_defaults')]
#[ORM\UniqueConstraint(name: 'uniq_insurance_service_category', columns: ['insurance_id', 'service_category'])]
class InsuranceCoverageDefault
{
    #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(name: 'insurance_id', type: 'integer')]
    private int $insuranceId;

    #[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
    private ServiceCategory $serviceCategory;

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

    #[ORM\Column(name: 'updated_at', type: 'integer')]
    private int $updatedAt;
    // getters/setters + toArray() طبق الگوی سایر Entityهای Insurance
}
```

متد ریپازیتوری لازم:

```php
/** @return array<string, float> service_category => percent */
public function percentMapFor(int $insuranceId): array;

/** @return array<int, array<string, float>> insurance_id => (category => percent) — برای پرکردن لیست‌ها بدون N+1 */
public function percentMapForMany(array $insuranceIds): array;
```

سپس:

```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```

در همان migration، **backfill**: برای هر بیمهٔ `basic` یک ردیف `outpatient` و یک `inpatient` با مقدار ۰ ساخته شود تا پنل ادمین همیشه ردیف کامل نشان دهد (خالی‌بودن = «تعریف‌نشده» نه «صفرِ عمدی»).

### ۳. اندپوینت‌های ادمین برای درصدهای پیش‌فرض

در `src/Insurance/Controller/InsuranceController.php` (کنار `/api/v1/admin/insurance/...`، همان `#[IsGranted('ROLE_ADMIN')]` که بقیهٔ اکشن‌های ادمین دارند):

```php
#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function getCoverageDefaults(int $id): JsonResponse
{
    // { categories: [ { key, label, coverage_percent } ] } — همیشه همهٔ caseهای ServiceCategory
}

#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['PUT'])]
#[IsGranted('ROLE_ADMIN')]
public function saveCoverageDefaults(int $id, Request $request): JsonResponse
{
    // body: { categories: [ { key: 'outpatient', coverage_percent: 30 }, ... ] }
    // اعتبارسنجی: key ∈ ServiceCategory::values() و 0 ≤ percent ≤ 100 → در غیر این صورت
    // $this->error(ErrorCodes::ERR_VALIDATION_001, ..., 422, 'coverage_percent')
}
```

- در پاسخ `GET /api/v1/admin/insurances` (لیست ادمین) هم `coverage_defaults` هر بیمه ضمیمه شود (با `percentMapForMany`، بدون N+1).
- در `GET /api/v1/insurances` و در `pricingPayload()` (خروجی `GET /api/v1/insurance-pricing`) هم برای هر بیمه کلید `coverage_defaults: { outpatient: 30, inpatient: 0 }` اضافه شود — پنل پزشک از همین برای پیش‌فرض استفاده می‌کند.

### ۴. درصدهای قرارداد در سطح نوع خدمت

Entity جدید `src/Insurance/Entity/TenantInsuranceCategoryCoverage.php` (`tenant_insurance_category_coverage`): `tenant_insurance_id`, `service_category`, `coverage_percent`, `updated_at` با `UniqueConstraint` روی دو ستون اول.

- در `POST/PATCH /api/v1/billing/tenant-insurances` بدنه یک آرایهٔ اختیاری بگیرد:
  ```json
  { "insurance_id": 3, "kind": "basic",
    "category_coverages": [ { "key": "outpatient", "coverage_percent": 70 },
                            { "key": "inpatient",  "coverage_percent": 30 } ] }
  ```
- اگر `category_coverages` ارسال نشد → **هیچ ردیفی ساخته نشود**؛ محاسبه به پیش‌فرض ادمین برمی‌گردد (fallback زنده، نه کپی). این مهم است: با تغییر قوانین بیمه در پنل ادمین، قراردادهایی که override نکرده‌اند خودبه‌خود به‌روز می‌شوند.
- `GET /api/v1/billing/tenant-insurances` برای هر قرارداد برگرداند:
  ```json
  { "coverage_percent": 70,
    "category_coverages": { "outpatient": 70, "inpatient": 30 },
    "category_coverage_source": { "outpatient": "override", "inpatient": "admin_default" } }
  ```
  تا فرانت بتواند نشان دهد کدام مقدار از تنظیمات مرکزی آمده است.
- مجوز override: همان `insurances.update` که در کنترلر با `secretaryAccess`/`clinicDoctorAccess` چک می‌شود؛ اگر کاربر مجوز ندارد و `category_coverages` فرستاده، `ERR_FORBIDDEN_001` برگردد.
- `TenantInsuranceCleanupService` (حذف قرارداد) باید ردیف‌های این جدول را هم پاک کند.

### ۵. نوع خدمت روی `ServiceItem`

- ستون جدید `service_category` روی `src/ClinicService/Entity/ServiceItem.php` با `enumType: ServiceCategory::class` و پیش‌فرض `outpatient`؛ در `toArray()` هم برگردد.
- ویزیت (که آیتم سرویس نیست) **همیشه `outpatient`** است؛ این را به‌صورت ثابت در `PatientService::calculateFinalPrice()` و `InvoiceService::createFromSession()` اعمال کن، نه با مقدار جادویی پراکنده.
- در `ServiceItemFormModal.tsx` یک `SearchableSelect` (طبق قاعدهٔ پروژه: هرگز `<select>` بومی) برای «نوع خدمت» با گزینه‌های گرفته‌شده از API اضافه شود.
- migration + backfill: همهٔ ردیف‌های موجود = `outpatient`.

### ۶. اصلاح `CoverageRule` و `BillingCalculator`

`CoverageRule` نوع خدمت را نمی‌شناسد و نباید بشناسد؛ فقط منطق فرانشیز اصلاح می‌شود:

```php
final readonly class CoverageRule
{
    public function __construct(
        public float $coveragePercent,
        public int   $franchiseRials,   // فقط برای بیمهٔ تکمیلی معنا دارد
        public ?int  $ceilingRials,
        public bool  $covered = true,
    ) {}
}
```

در `BillingCalculator::calculateItem()` فرانشیزِ **پایه** حذف شود:

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

و `TenantInsuranceService::coverageRule*()` هنگام ساخت قاعدهٔ پایه `franchiseRials: 0` بدهد (ستون DB برای سازگاری باقی می‌ماند ولی در محاسبهٔ پایه دخالت نمی‌کند).

سناریوی مرجع باید دقیقاً برقرار شود: `calculateItem(new Money(5_952_000), new CoverageRule(30.0, 0, null), null)` →
`base_insurance_rials = 1_785_600` و `patient_rials = 4_166_400`.

### ۷. زنجیرهٔ resolve درصد پوشش

در `TenantInsuranceService` یک متد خصوصی اضافه شود و هر دو متد `coverageRule()` / `coverageRuleForService()` از آن استفاده کنند:

```php
/**
 * درصد پوشش مؤثر، به ترتیب اولویت:
 *   ۱) override همان خدمت (TenantServiceCoverage.coverage_percent)
 *   ۲) override قرارداد برای نوع خدمت (TenantInsuranceCategoryCoverage)
 *   ۳) پیش‌فرض مرکزی ادمین (InsuranceCoverageDefault)
 *   ۴) coverage_percent قرارداد (سازگاری با ردیف‌های قدیمی)
 */
private function resolvePercent(
    TenantInsurance $contract,
    ServiceCategory $category,
    ?TenantServiceCoverage $override,
): float
```

`coverageRule()` (ویزیت) با `ServiceCategory::Outpatient` صدا زده شود؛ `coverageRuleForService()` با `service_category` همان `ServiceItem`.

`PatientService::contractPercent()` (snapshot روی `base_insurance_discount_percent` مراجعه) هم از همین زنجیره تغذیه شود — این فیلد فقط snapshot نمایشی است و نباید ورودی محاسبه باشد (همان‌طور که در docblock فعلی نوشته شده).

### ۸. پنل ادمین اصلی — بخش تنظیمات درصدهای هر بیمه

در `assets/admin/pages/CategoriesPage.tsx` (تب «بیمه‌ها»، اطراف خط ۸۷۰ به بعد):

- در هر ردیف بیمه یک اکشن «تنظیمات پوشش» اضافه شود که مودالی باز کند (از `components/ui/Modal` موجود؛ طراحی جدید نساز — همان تم/کامپوننت‌های موجود).
- مودال از `GET /api/v1/admin/insurance/{id}/coverage-defaults` می‌خواند و یک ردیف ورودی درصد به‌ازای هر category برمی‌گرداند (لیست از API، نه hardcode) و با `PUT` ذخیره می‌کند؛ سپس `queryKey` لیست بیمه‌ها invalidate شود.
- اعتبارسنجی کلاینت: عدد ۰ تا ۱۰۰، با `digitsOnly(value, 3)` مثل `InsuranceModal`.
- برای بیمهٔ تکمیلی هم همین بخش نمایش داده شود (ساختار یکسان)، ولی متن راهنما شفاف کند که سهم تکمیلی روی **باقیماندهٔ پس از پایه** اعمال می‌شود.

### ۹. پنل پزشک/کلینیک — پیش‌فرض‌گیری از تنظیمات مرکزی

`assets/admin/components/InsuranceModal.tsx`:

- `InsuranceFormValues` به‌جای `coverage: string` تک‌مقداری، `categoryPercents: Record<string, string>` بگیرد.
- با انتخاب بیمه در حالت «افزودن»، مقادیر از `coverage_defaults` همان بیمه (از `pricingQuery`) پر شوند؛ در حالت «ویرایش»، از `category_coverages` قرارداد.
- ورودی‌های درصد فقط وقتی قابل ویرایش باشند که `canUpdate` (از `usePermissions`) درست باشد؛ در غیر این صورت `readOnly` با متن راهنما «مقدار پیش‌فرض تنظیمات مرکزی».
- زیر هر ورودی که هنوز override نشده، برچسب کوچک «پیش‌فرض ادمین» نمایش داده شود (`category_coverage_source`).
- فیلد «فرانشیز (تومان)» برای قراردادهای `basic` حذف شود (در `buildInsurancePayload` برای basic همیشه `franchise_rials: 0`)؛ برای `supplementary` باقی بماند.

`assets/admin/components/TenantInsuranceContracts.tsx`:

- `contractSummary()` به‌جای «پوشش X٪» درصدها را به تفکیک نشان دهد: `سرپایی ۷۰٪ · بستری ۳۰٪ · سقف پوشش …`؛ فرانشیز فقط در قراردادهای تکمیلی.
- در `ContractDetails` به‌ازای هر category یک `DetailCell` رندر شود.

`assets/admin/pages/MyPatientsPage.tsx`:

- `shareToDiscountPercent` **حذف** شود. `applyBaseInsurance`/`applySuppInsurance` درصد را از قرارداد فعال همان بیمه (`/api/v1/billing/tenant-insurances`) بگیرند، با category پیش‌فرض `outpatient` برای ویزیت.
- `patient_share_rials` دیگر ورودی محاسبه نیست؛ نقش `EntityInsurancePricing` فقط «قیمت ویزیت آزاد» و `require_visit_price` باقی می‌ماند.

`assets/admin/components/session/CreateStep.tsx`:

- `patientShareOf` (آینهٔ سرور) با قاعدهٔ جدید هم‌تراز شود: فرانشیز پایه حذف، و `ruleFor()` درصد را بر اساس `service_category` خدمت انتخاب کند.
- `coverageOf(id)` برای ویزیت درصد `outpatient` را برگرداند.

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

- `docs/api/insurance.md`: دو اندپوینت جدید coverage-defaults، فیلدهای جدید در `GET /api/v1/insurances`، `GET /api/v1/insurance-pricing`، و `GET/POST/PATCH /api/v1/billing/tenant-insurances`.
- `docs/api/billing.md` و `docs/api/patient.md`: فرمول جدید سهم پایه/بیمار و بی‌اثرشدن فرانشیز در بیمهٔ پایه.
- `docs/api/clinic-services.md`: فیلد `service_category` روی خدمت.
- PHPUnit:
  - `BillingCalculator`: سناریوی مرجع ۵,۹۵۲,۰۰۰ ریال با ۳۰٪؛ پایه+تکمیلی پشت‌سرهم؛ سقف تعهد؛ درصد ۰ و ۱۰۰؛ `notCovered`.
  - `TenantInsuranceService::resolvePercent`: هر چهار سطح زنجیره + رفتار fallback پس از تغییر پیش‌فرض ادمین.
  - Controller: `PUT coverage-defaults` با درصد ۱۰۱ → ۴۲۲؛ کاربر غیر ادمین → ۴۰۳.
- Vitest: `InsuranceModal` (پیش‌فرض‌گیری از `coverage_defaults`، readOnly بدون مجوز)، `contractSummary`، `patientShareOf` در `CreateStep`.
- اجرای واقعی: `ddev exec php bin/phpunit`، `ddev exec php vendor/bin/phpstan analyse`، `yarn test`، `npx tsc --noEmit --project tsconfig.json`.

## نکات مهم

- **واحد پول:** ذخیره‌سازی و API ریال است؛ ورودی/نمایش پنل تومان (`tomanToRial` / `rialToToman` / `formatRial`). سناریوی کاربر با تومان نوشته شده — در تست‌ها ریال بگذار.
- **گرد کردن:** یک قاعده در کل سیستم — `Money::percent()` سمت سرور و `Math.round(total * percent / 100)` سمت کلاینت. سهم بیمار همیشه `total − baseShare` است، نه یک محاسبهٔ درصدیِ مستقل؛ در غیر این صورت مجموع دو سهم با کل برابر نمی‌شود.
- **منبع واحد محاسبه:** فقط `BillingCalculator`. `PatientService::calculateFinalPrice()` و `InvoiceService::createFromSession()` هر دو از آن عبور می‌کنند و نباید محاسبهٔ موازی اضافه شود. کد فرانت آینه است و حق ندارد به تنهایی مبنای ثبت باشد.
- **سازگاری با دادهٔ موجود:** ستون‌های `tenant_insurances.coverage_percent` و `franchise_rials` حذف نشوند (سطح ۴ زنجیره + قراردادهای تکمیلی). فقط از مسیر محاسبهٔ بیمهٔ پایه خارج می‌شوند.
- **fallback زنده، نه کپی:** قرارداد بدون override نباید درصد را در جدول خودش snapshot کند؛ در غیر این صورت هدف «مدیریت مرکزی» نقض می‌شود.
- **کالاهای مصرفی** (`SessionConsumable`) بدون پوشش بیمه‌اند — این رفتار تغییر نکند.
- **`ServiceItem.insurance_covered`** همچنان gate نهایی است: خدمتی که این پرچم را ندارد، هرچقدر هم درصد تعریف شده باشد، `notCovered` می‌ماند.
- **الگوهای پروژه:** پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()`؛ لیست‌های ادمین با `getArrayResult()`؛ تاریخ‌ها Unix timestamp؛ رشته‌های UI فارسی؛ در فرانت به‌جای `<select>` از `SearchableSelect`؛ صفحه/مودال جدید با همان تم و کامپوننت‌های موجود، بدون طراحی تازه.
- **پاسخ‌های تودرتو:** `tenant-insurances` در فرانت با `(data as any)?.data?.data` خوانده می‌شود — اگر شکل پاسخ را تغییر دادی، هر سه مصرف‌کننده (`TenantInsuranceContracts`, `CreateStep`, `MyPatientsPage`) را هم‌زمان اصلاح کن.
- بعد از اتمام: `graphify update .` (پس از commit).
