# اصلاح محاسبه بیمه پایه بر پایه «درصد پوشش» + تنظیمات مرکزی درصدها در پنل ادمین ## پروژه `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
set({ coverage: digitsOnly(e.target.value, 3) })} />
set({ franchise: digitsOnly(e.target.value) })} />
...
``` ### ۴) قاعدهٔ پوشش، بی‌خبر از نوع خدمت — `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 */ 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 service_category => percent */ public function percentMapFor(int $insuranceId): array; /** @return array> 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` (طبق قاعدهٔ پروژه: هرگز `` از `SearchableSelect`؛ صفحه/مودال جدید با همان تم و کامپوننت‌های موجود، بدون طراحی تازه. - **پاسخ‌های تودرتو:** `tenant-insurances` در فرانت با `(data as any)?.data?.data` خوانده می‌شود — اگر شکل پاسخ را تغییر دادی، هر سه مصرف‌کننده (`TenantInsuranceContracts`, `CreateStep`, `MyPatientsPage`) را هم‌زمان اصلاح کن. - بعد از اتمام: `graphify update .` (پس از commit).