feat(insurance): resolve coverage percent per service category

Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
@@ -0,0 +1,376 @@
# اصلاح محاسبه بیمه پایه بر پایه «درصد پوشش» + تنظیمات مرکزی درصدها در پنل ادمین
## پروژه
`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).