Files
clinicpro/.claude/prompt/insurance-coverage-percent-rework.md
hamedandClaude Opus 5 58c6d9ac18 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>
2026-07-25 16:21:19 +03:30

25 KiB
Raw Permalink Blame History

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

پروژه

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

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

// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. 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

<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

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:

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:

#[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
}

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

/** @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;

سپس:

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')] که بقیهٔ اکشن‌های ادمین دارند):

#[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 بدنه یک آرایهٔ اختیاری بگیرد:
    { "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 برای هر قرارداد برگرداند:
    { "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 نوع خدمت را نمی‌شناسد و نباید بشناسد؛ فقط منطق فرانشیز اصلاح می‌شود:

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

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

// بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ تکمیلی معنا دارد.
$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() از آن استفاده کنند:

/**
 * درصد پوشش مؤثر، به ترتیب اولویت:
 *   ۱) 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).