# سیستم صورتحساب و بیمه (Insurance & Medical Billing) — سند معماری > وضعیت: **طرح معماری (Design)** — هنوز پیاده‌سازی نشده. بر اساس کد فعلی Clinic Pro (Symfony 7.4 / PHP 8.2+ / Doctrine / MariaDB) و الگوهای موجود نوشته شده. > دامنه‌های جدید پیشنهادی: `App\Billing\*` (Invoice/InvoiceItem/Claim/Tariff) و توسعه‌ی `App\Insurance\*` (TenantInsurance/Coverage). --- ## ۰. تطبیق با کد فعلی (مبنای واقعی، نه greenfield) | مفهوم پرامپت | معادل واقعی در Clinic Pro | تصمیم | |---|---|---| | **Tenant** | موجودیت مستقل وجود ندارد. tenancy به‌صورت polymorphic `(entityType ∈ {doctor, clinic}, entityId)` پیاده شده (الگوی `PatientRecord`, `EntityInsurancePricing`, `SmsSettings`, `SubscriptionService`). | یک **Value Object `TenantRef(type, id)`** معرفی می‌شود؛ موجودیت Tenant جدید **ساخته نمی‌شود**. resolve از `#[CurrentUser]` مثل `PatientController::resolveEntity`. | | **InsuranceCompany** | `App\Insurance\Entity\Insurance` (لیست master سراسری، مدیریت‌شده توسط admin، دارای `type: InsuranceType{basic,supplementary}`). | حفظ می‌شود به‌عنوان **master سراسری**. tenant آن را «فعال» می‌کند، تعریف نمی‌کند. | | **TenantInsurance** | `EntityInsurancePricing` فعلی (polymorphic، فقط `patient_share_rials`) — **ناکافی**. | به **`TenantInsurance`** ارتقا می‌یابد (قرارداد tenant↔insurance با نسخه و وضعیت). `EntityInsurancePricing` migrate می‌شود. | | **BaseInsurance / SupplementaryInsurance** | `InsuranceType` enum (`basic`/`supplementary`). | همان enum؛ موجودیت جدا لازم نیست (نوع روی `Insurance` است). | | **Service** | `App\ClinicService\Entity\ServiceItem` (دارای `priceRials`, `active`, متعلق به `ServiceSection`). | حفظ + افزودن `insuranceCovered`. تعرفه به `Tariff` منتقل می‌شود. | | **Tariff** | وجود ندارد (قیمت مستقیم روی `ServiceItem.priceRials`). | موجودیت جدید `Tariff` با **نسخه‌بندی سالانه** (effective year/range). | | **Encounter** | `App\Patient\Entity\PatientSession` (مراجعه؛ دارای فیلدهای بیمه/قیمت ساده). | `PatientSession` نقش Encounter را دارد؛ **Invoice** به آن متصل می‌شود. | | **Invoice / InvoiceItem** | وجود ندارد (محاسبه‌ی ساده در `PatientService::calculateFinalPrice`). | موجودیت‌های جدید `Invoice`/`InvoiceItem` با تفکیک سهم بیمار/پایه/مکمل. | | **Claim** | وجود ندارد. | موجودیت جدید با state machine (الگوی `Appointment::transitionTo`). | | **Patient** | `PatientRecord` (+ `UserProfile` برای دموگرافیک/بیمه‌ی بیمار). | حفظ. بیمه‌ی بیمار از `UserProfile.basicInsuranceId/supplementaryInsuranceId`. | | **Payment** | `App\Payment\Entity\Payment` (درگاه پرداخت). | سهم بیمار از Invoice → می‌تواند به Payment وصل شود. | **اصول حفظ‌شده:** پول = `int` ریال؛ تاریخ = Unix timestamp (نه DateTime)؛ کنترلرها از `BaseController` (`success/paginated/error`)؛ لیست admin با DQL array hydration؛ خطاها در `ErrorCodes`؛ multi-tenant با `(entityType, entityId)`. --- ## ۱. Bounded Contexts ``` Insurance (master + tenant contracts + coverage) Insurance (master, global, admin) ← موجود TenantInsurance (قرارداد tenant↔insurance) ← ارتقای EntityInsurancePricing InsuranceCoverage (قانون پوشش هر بیمه) TenantServiceCoverage (override پوشش یک خدمت خاص) Billing (encounter → invoice → claim) Tariff (تعرفه‌ی نسخه‌دار سالانه) Invoice / InvoiceItem Claim (چرخه‌ی مطالبات بیمه) BillingCalculator (دامنه‌ی محاسبه — Domain Service) Patient (موجود) ClinicService (موجود + insuranceCovered) Subscription (موجود — gate ویژگی billing) ``` --- ## ۲. Domain Model — موجودیت‌ها و فیلدها ### 2.1 Value Objects ```php // App\Shared\ValueObject\TenantRef final readonly class TenantRef { public function __construct(public string $type, public int $id) {} // type: 'doctor'|'clinic' public static function doctor(int $id): self { return new self('doctor', $id); } public static function clinic(int $id): self { return new self('clinic', $id); } } // App\Billing\ValueObject\Money (ریال، صحیح، بدون منفی) final readonly class Money { public function __construct(public int $rials) { if ($rials < 0) throw new \InvalidArgumentException('negative money'); } public function add(Money $o): self { return new self($this->rials + $o->rials); } public function sub(Money $o): self { return new self(max(0, $this->rials - $o->rials)); } public function percent(float $p): self { return new self((int) round($this->rials * $p / 100)); } public function min(Money $o): self { return new self(min($this->rials, $o->rials)); } } // App\Billing\ValueObject\ShareBreakdown (خروجی محاسبه‌ی یک آیتم) final readonly class ShareBreakdown { public function __construct( public int $totalRials, public int $baseInsuranceRials, public int $supplementaryRials, public int $patientRials, ) {} } // App\Insurance\ValueObject\CoverageRule final readonly class CoverageRule { public function __construct( public float $coveragePercent, // درصد پوشش این بیمه public int $franchiseRials, // فرانشیز ثابت سهم بیمار public ?int $ceilingRials, // سقف تعهد هر آیتم (null = بی‌نهایت) public bool $covered = true, ) {} } ``` ### 2.2 Entities (جداول) #### TenantInsurance (ارتقای EntityInsurancePricing) قرارداد یک tenant با یک بیمه. نسخه‌دار، قابل غیرفعال‌سازی بدون حذف. | ستون | نوع | توضیح | |---|---|---| | id | int PK | | | uuid | string(36) unique | | | entity_type | string(10) | `doctor`/`clinic` | | entity_id | int | | | insurance_id | int FK→insurances | | | version | int | نسخه‌ی قرارداد (۱، ۲، …) | | is_active | bool | غیرفعال بدون حذف | | coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد | | franchise_percent | decimal(5,2) | فرانشیز **درصدی** — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد | | annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه | | effective_from | int (unix) | شروع اعتبار قرارداد | | effective_to | int null | پایان (null = جاری) | | created_at / updated_at | int | | UniqueConstraint: `(entity_type, entity_id, insurance_id, version)`. Index: `(entity_type, entity_id, is_active)`. #### TenantServiceCoverage override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا پوشش دارد + درصد/فرانشیز/سقف اختصاصی). | ستون | نوع | |---|---| | id, uuid | | | tenant_insurance_id | int FK→tenant_insurances | | service_item_id | int FK→service_items | | covered | bool | | coverage_percent | decimal(5,2) null (null=ارث از قرارداد) | | franchise_percent | decimal(5,2) null (null=ارث از قرارداد) | | ceiling_rials | int null | UniqueConstraint: `(tenant_insurance_id, service_item_id)`. #### Tariff (تعرفه‌ی نسخه‌دار) تعرفه‌ی یک خدمت برای یک سال. تاریخچه حفظ می‌شود. | ستون | نوع | توضیح | |---|---|---| | id, uuid | | | | service_item_id | int FK | | | year | smallint | سال شمسی (۱۴۰۳ …) | | price_rials | int | تعرفه‌ی آن سال | | effective_from / effective_to | int (unix) | بازه‌ی اعتبار | | is_active | bool | | UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌عنوان تعرفه‌ی پیش‌فرض/جاری باقی می‌ماند (fallback). #### Invoice صورتحساب یک Encounter (`PatientSession`). | ستون | نوع | |---|---| | id, uuid | | | entity_type, entity_id | tenant | | patient_session_id | int FK→patient_sessions (Encounter) | | patient_record_id | int FK | | base_insurance_id | int null | | supplementary_insurance_id | int null | | total_rials | int | | base_insurance_rials | int | | supplementary_rials | int | | patient_rials | int | | status | string(15) `draft\|finalized\|paid\|void` | | issued_at | int (unix) | | created_at, updated_at | int | #### InvoiceItem یک خط صورتحساب (خدمت/ویزیت). | ستون | نوع | |---|---| | id, uuid | | | invoice_id | int FK | | service_item_id | int null (ویزیت می‌تواند null باشد) | | title | string | | service_code | string null | | tariff_rials | int (تعرفه‌ی واحد) | | quantity | int | | total_rials | int (= tariff × qty) | | base_insurance_rials | int | | supplementary_rials | int | | patient_rials | int | #### Claim (مطالبه‌ی بیمه) گروهی از Invoiceها/InvoiceItemها که به یک بیمه ارسال می‌شود. | ستون | نوع | |---|---| | id, uuid | | | entity_type, entity_id | tenant | | insurance_id | int FK | | insurance_kind | string `base\|supplementary` | | total_claimed_rials | int | | total_approved_rials | int null | | total_paid_rials | int null | | status | string(15) `pending\|submitted\|approved\|rejected\|paid` | | reject_reason | text null | | submitted_at / settled_at | int null | | created_at, updated_at | int | #### ClaimItem ارتباط Claim ↔ InvoiceItem (many-to-many با مبلغ ادعاشده per item). | ستون | نوع | |---|---| | id | | | claim_id | int FK | | invoice_item_id | int FK | | claimed_rials | int | | approved_rials | int null | --- ## ۳. Entity Diagram (ERD متنی) ``` Insurance (master, global) ▲ insurance_id │ TenantInsurance ──< TenantServiceCoverage >── ServiceItem ──< Tariff (entity_type,id) │ service_item_id │ PatientRecord ──< PatientSession(Encounter) ──1:1 Invoice ──< InvoiceItem │ ▲ invoice_item_id │ │ base/supp ins ClaimItem >── Claim ── Insurance (entity_type,id) UserProfile.basicInsuranceId / supplementaryInsuranceId → بیمه‌ی پیش‌فرض بیمار ``` --- ## ۴. Aggregates (DDD) | Aggregate Root | شامل | Invariantها | |---|---|---| | **Invoice** | InvoiceItem[] | جمع سهم‌ها = total هر آیتم؛ مجموع سطرها = total فاکتور؛ تغییر فقط در `draft`. | | **Claim** | ClaimItem[] | فقط InvoiceItemهای finalized؛ transition وضعیت طبق state machine؛ `approved_rials ≤ claimed_rials`. | | **TenantInsurance** | TenantServiceCoverage[] | فقط بیمه‌های فعالِ همان tenant؛ نسخه‌ی جدید قبلی را غیرفعال نمی‌کند مگر صریح. | Encounter (`PatientSession`) خارج از Aggregate صورتحساب است؛ Invoice به آن **ارجاع** می‌دهد (نه ownership). --- ## ۵. منطق محاسبه‌ی سهم (Domain Service) ```php // App\Billing\Service\BillingCalculator final class BillingCalculator { /** * محاسبه‌ی سهم برای یک آیتم. * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده‌ی بیمار → پوشش مکمل روی باقیمانده → فرانشیز ثابت سهم بیمار. */ public function calculateItem( Money $total, ?CoverageRule $base, // قانون بیمه‌ی پایه (یا null) ?CoverageRule $supplementary, // قانون بیمه‌ی مکمل (یا null) ): ShareBreakdown { $baseShare = new Money(0); $remaining = $total; 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); } $suppShare = new Money(0); 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); } // فرانشیز همیشه سهم بیمار است (به remaining اضافه می‌شود، از سهم بیمه کم نمی‌کند مگر طراحی دیگر) $franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0)); $patient = $remaining->add($franchise)->min($total); // سهم بیمار از کل بیشتر نشود // اصلاح: اگر فرانشیز باعث شد جمع > total شود، سهم بیمه‌ها کم نمی‌شود؛ این Invariant باید تست شود. return new ShareBreakdown( totalRials: $total->rials, baseInsuranceRials: $baseShare->rials, supplementaryRials: $suppShare->rials, patientRials: $patient->rials, ); } } ``` **نمونه (مطابق پرامپت):** کل 600,000؛ پایه 70% → 420,000؛ مکمل روی باقیمانده‌ی 180,000 با ≈66.7% → 120,000؛ سهم بیمار 60,000. > نکته‌ی طراحی: تعامل فرانشیز و سقف باید با Test پوشش داده شود؛ منطق بالا یک baseline است و قابل تنظیم per-insurance. --- ## ۶. Repository / Service / DTO **Repositoryها** (الگوی `ServiceEntityRepository` + `save/remove`): `TenantInsuranceRepository` (findActiveByTenant, findContractFor), `TenantServiceCoverageRepository`, `TariffRepository` (findForServiceYear), `InvoiceRepository`, `ClaimRepository` (findByStatus, debtReport). **Service Layer:** - `TenantInsuranceService` — فعال‌سازی/غیرفعال/نسخه‌بندی قرارداد، گرفتن `CoverageRule` برای (tenant, insurance, serviceItem). - `BillingCalculator` — محاسبه‌ی خالص (بالا). - `InvoiceService` — ساخت Invoice از Encounter: برای هر خدمت → resolve تعرفه (Tariff سال) + CoverageRule بیمه‌های بیمار → `BillingCalculator` → InvoiceItem؛ finalize. - `ClaimService` — گروه‌بندی InvoiceItemها بر اساس بیمه، ساخت Claim، transition وضعیت، گزارش بدهی. **DTOها:** `CreateInvoiceRequest`, `InvoiceItemDTO`, `TenantInsuranceDTO`, `CoverageRuleDTO`, `ClaimDTO`, `BillingPreviewResponse` (پیش‌نمایش زنده‌ی محاسبه قبل از ثبت). --- ## ۷. REST API (تحت `/api/v1`, `BaseController`, tenant از `#[CurrentUser]`) ### TenantInsurance (قراردادهای بیمه‌ی tenant) - `GET /api/v1/billing/tenant-insurances` — لیست قراردادهای فعال tenant - `POST /api/v1/billing/tenant-insurances` — فعال‌سازی بیمه برای tenant (با coverage_percent/franchise/ceiling) - `PATCH /api/v1/billing/tenant-insurances/{uuid}` — ویرایش (نسخه‌ی جدید) - `DELETE /api/v1/billing/tenant-insurances/{uuid}` — غیرفعال‌سازی (soft) - `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` — تنظیم پوشش خدمات ### Tariff - `GET /api/v1/billing/services/{serviceUuid}/tariffs` - `PUT /api/v1/billing/services/{serviceUuid}/tariffs/{year}` — ثبت/ویرایش تعرفه‌ی سال ### Invoice - `POST /api/v1/billing/invoices/preview` — پیش‌نمایش محاسبه (بدون ذخیره) - `POST /api/v1/billing/invoices` — ساخت از Encounter - `GET /api/v1/billing/invoices/{uuid}` - `POST /api/v1/billing/invoices/{uuid}/finalize` ### Claim - `POST /api/v1/billing/claims` — ساخت از InvoiceItemهای finalized یک بیمه - `POST /api/v1/billing/claims/{uuid}/submit|approve|reject|pay` - `GET /api/v1/billing/claims?status=...` - `GET /api/v1/billing/reports/insurance-debt` — گزارش بدهی بیمه‌ها **Guard مهم (نیازمندی ۱۴):** هنگام پذیرش بیمار و ساخت Invoice، فقط بیمه‌هایی مجازند که `TenantInsurance.is_active = true` برای همان tenant. در غیر این صورت `ERR_VALIDATION` («این بیمه برای این کلینیک فعال نیست»). --- ## ۸. Workflow ### ثبت صورتحساب ``` Encounter(PatientSession) ثبت می‌شود → بیمه‌ی بیمار از UserProfile یا انتخاب منشی (محدود به TenantInsurance فعال) → InvoiceService.createFromEncounter: برای هر خدمت: tariff = TariffRepository.findForServiceYear(service, سالِ جاری) ?? service.priceRials coverage_base = TenantInsuranceService.coverageRule(tenant, baseIns, service) coverage_supp = TenantInsuranceService.coverageRule(tenant, suppIns, service) breakdown = BillingCalculator.calculateItem(...) → InvoiceItem → Invoice (status=draft) → finalize (status=finalized) ``` ### Claim ``` InvoiceItemهای finalized یک بیمه → ClaimService.create (status=pending) → submit (submitted) → [پاسخ بیمه] approve/reject → pay (paid) گزارش بدهی = جمع claimed - paid بر اساس بیمه ``` state machine Claim (الگوی `Appointment::canTransitionTo`): `pending → submitted → {approved → paid | rejected}`؛ از `paid`/`rejected` خروج ندارد. --- ## ۹. Events / Use Cases **Domain Events:** `InvoiceFinalized`, `ClaimSubmitted`, `ClaimApproved`, `ClaimRejected`, `ClaimPaid`, `TenantInsuranceDeactivated`. کاربرد: SMS/notification به بیمار، به‌روزرسانی گزارش بدهی، آینده: ارسال خودکار به API بیمه. **Use Cases اصلی:** فعال‌سازی بیمه برای tenant؛ تنظیم پوشش خدمت؛ ثبت تعرفه‌ی سالانه؛ پیش‌نمایش محاسبه‌ی سهم؛ ثبت Invoice از Encounter؛ ساخت/پیگیری Claim؛ گزارش بدهی. --- ## ۱۰. آینده: اتصال به API بیمه (نیازمندی ۱۰) `ClaimSubmitter` به‌صورت interface طراحی شود؛ پیاده‌سازی فعلی manual، آینده `IranInsuranceApiSubmitter` بدون تغییر در دامنه (Dependency Inversion). --- ## ۱۱. فازبندی پیاده‌سازی (پیشنهادی) 1. **فاز ۱ — قرارداد بیمه:** ✅ **انجام شد** — `TenantInsurance` (entity/repo/service/API/UI) + جدول `tenant_insurances` + guard `assertActive()` + `CoverageRule` VO. `EntityInsurancePricing` فعلاً برای ویزیت آزاد/سهم ساده باقی ماند (فاز ۴ ادغام). API: `/api/v1/billing/tenant-insurances` (GET/POST/PATCH/DELETE). UI: صفحه‌ی «بیمه و قیمت‌گذاری». 2. **فاز ۲ — پوشش خدمت:** ✅ **انجام شد** — `ServiceItem.insuranceCovered` + `insurancePriceRials` + موجودیت `TenantServiceCoverage` (override per-service per-contract) + `coverageRuleForService()` (resolve با ارث از قرارداد) + API `…/service-coverage` (GET/PUT) + UI toggle «شامل بیمه» در صفحه‌ی خدمات. 3. **فاز ۳ — تعرفه:** ✅ **انجام شد** — موجودیت `Tariff` (service_item × سال شمسی) + `TariffService::resolvePrice()` با fallback به `ServiceItem.priceRials` + `currentJalaliYear()` (IntlDateFormatter، رقم لاتین) + API `…/tariffs` (GET) و `…/tariffs/{year}` (PUT) + UI «تعرفه‌های سالانه» (modal از ردیف خدمت). 4. **فاز ۴ — محاسبه + Invoice:** ✅ **انجام شد** — VO `Money`/`ShareBreakdown` + `BillingCalculator` (۶ تست واحد سبز، نمونه ۶۰۰k) + `Invoice`/`InvoiceItem` (aggregate، جداول `invoices`/`invoice_items`) + `InvoiceService::createFromSession` (resolve تعرفه‌ی Tariff + CoverageRule per-service + محاسبه) + API `/api/v1/billing/invoices` (POST/GET/finalize). `PatientService::calculateFinalPrice` فعلاً برای سازگاری باقی ماند (مصرف UI موجود). 5. **فاز ۵ — Claim + گزارش:** ✅ **انجام شد** — `Claim`/`ClaimItem` (جداول `claims`/`claim_items`) + state machine (`pending→submitted→approved/rejected→paid`) + `ClaimService::createFromInvoice` (تفکیک پایه/مکمل از InvoiceItemها) + transitions + API `/api/v1/billing/claims` (POST/GET + `{uuid}/{action}`) + گزارش `…/reports/insurance-debt`. 6. **فاز ۶ — Frontend + API بیمه:** ✅ **انجام شد** — صفحه‌ی «مطالبات بیمه» (`ClaimsPage`: لیست + فیلتر وضعیت + transitions submit/approve/reject/pay + گزارش بدهی) + route `/admin/claims` + آیتم sidebar (doctor/clinic) + interface `ClaimSubmitterInterface` با پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` (autowired؛ آماده‌ی جایگزینی با API بیمه‌ی ایران بدون تغییر `ClaimService`). UI صورتحساب در فرم مراجعه به فاز بعدی موکول شد (نیاز به بازطراحی مودال مراجعه — پرامپت `new-visit-modal-ux.md`). > توجه: `EntityInsurancePricing` فعلی (همین session ساخته شد) در فاز ۱ به `TenantInsurance` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.