feat: insurance & medical billing system (6 phases)

Multi-tenant insurance contracts, service coverage, versioned tariffs,
invoice calculation, and insurance claims with debt reporting.

- TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling,
  versioning, soft-deactivate) + active guard
- ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides
- Tariff: versioned yearly tariffs with fallback to ServiceItem price
- Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested),
  Invoice/InvoiceItem aggregate, InvoiceService.createFromSession
- Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid),
  ClaimService, insurance-debt report
- ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready)
- Admin UI: insurance-pricing page, claims page, service tariff modal,
  service insurance toggle; routes + sidebar entries
- Architecture doc + billing/insurance/clinic-services API docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:05:24 +03:30
co-authored by Claude Opus 4.8
parent 5b1dfe9b40
commit 89191eee57
54 changed files with 4233 additions and 10 deletions
@@ -0,0 +1,399 @@
# سیستم صورتحساب و بیمه (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_rials | int | فرانشیز پیش‌فرض |
| 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_rials | int 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_coverage_percent | decimal(5,2) |
| supp_coverage_percent | decimal(5,2) |
| franchise_rials | int |
| ceiling_rials | int null |
| 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` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.