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
+144
View File
@@ -0,0 +1,144 @@
# Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
> **Prefix:** `/api/v1/billing`
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
> tenant از `#[CurrentUser]` resolve می‌شود (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic).
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته می‌شود. برای هر آیتم سهم بیمه‌ی پایه، بیمه‌ی مکمل و بیمار با `BillingCalculator` محاسبه می‌شود:
- تعرفه‌ی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل → فرانشیز سهم بیمار.
نمونه: کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
---
## POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده می‌شود (idempotent).
**Permission:** `AUTH` (مالک مراجعه)
**Body:**
```json
{ "session_uuid": "…" }
```
**Response 201:**
```json
{
"success": true,
"data": {
"data": {
"uuid": "…",
"entity_type": "doctor",
"entity_id": 7,
"patient_session_id": 33,
"base_insurance_id": 3,
"supplementary_insurance_id": 9,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000,
"status": "draft",
"issued_at": 1718900000,
"items": [
{
"uuid": "…",
"service_item_id": 12,
"title": "ویزیت",
"tariff_rials": 600000,
"quantity": 1,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000
}
]
}
}
}
```
**Errors:** `422 ERR_VALIDATION_001` session_uuid الزامی · `404 ERR_NOT_FOUND_001` مراجعه یافت نشد · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
---
## GET /api/v1/billing/invoices/{uuid}
دریافت صورتحساب (فقط مالک tenant).
**Response 200:** همان ساختار بالا.
**Errors:** `404 ERR_NOT_FOUND_001`.
---
## POST /api/v1/billing/invoices/{uuid}/finalize
نهایی‌سازی صورتحساب (`draft``finalized`). صورتحساب نهایی‌شده مبنای ساخت Claim (فاز ۵) است.
**Response 200:** صورتحساب با `status: "finalized"`.
**Errors:** `404 ERR_NOT_FOUND_001`.
---
## وضعیت‌های Invoice
`draft` (پیش‌نویس، قابل بازسازی) → `finalized` (نهایی) → `paid` (پرداخت‌شده) · `void` (باطل).
## نکات
- پول: integer ریال. تاریخ: Unix timestamp.
- `BillingCalculator` خالص و واحد-تست‌شده است (`tests/Billing/BillingCalculatorTest.php`).
- این فاز جایگزین تدریجی `PatientService::calculateFinalPrice` است؛ آن متد فعلاً برای سازگاری باقی مانده.
---
# Claims — مطالبات بیمه (فاز ۵)
مطالبه (`Claim`) از یک صورتحساب **نهایی‌شده** ساخته می‌شود: یک Claim برای بیمه‌ی پایه و یک Claim برای بیمه‌ی مکمل (فقط اگر سهم بیمه > ۰). هر `ClaimItem` به یک `InvoiceItem` ارجاع می‌دهد. چرخه‌ی وضعیت با state machine.
**وضعیت‌ها:** `pending → submitted → {approved → paid | rejected}`. از `paid`/`rejected` خروجی ندارد.
## POST /api/v1/billing/claims
ساخت مطالبات از یک صورتحساب نهایی‌شده.
**Body:** `{ "invoice_uuid": "…" }`
**Response 201:** `{ success, data: [ …claims ] }` (یک یا دو مطالبه: پایه/مکمل).
**Errors:** `422` صورتحساب نهایی نشده / سهم بیمه ندارد · `404` صورتحساب یافت نشد.
## GET /api/v1/billing/claims?status=
لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`).
## POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. `action``submit|approve|reject|pay`.
| action | body اختیاری | اثر |
|--------|--------------|-----|
| submit | — | pending → submitted |
| approve | `approved_rials` | submitted → approved (پیش‌فرض = کل ادعا) |
| reject | `reason` (الزامی) | submitted → rejected |
| pay | `paid_rials` | approved → paid (پیش‌فرض = approved) |
**Errors:** `422` انتقال نامعتبر یا دلیل رد خالی · `404` مطالبه یافت نشد.
## GET /api/v1/billing/reports/insurance-debt
گزارش بدهی بیمه‌ها برای tenant (group بر اساس بیمه).
**Response 200:**
```json
{
"success": true,
"data": {
"data": [
{ "insurance_id": 3, "claimed": 840000, "approved": 800000, "paid": 500000, "debt": 340000 }
]
}
}
```
`debt = claimed - paid` (حداقل صفر).
---
## ارسال مطالبه (ClaimSubmitter)
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود.
+52 -3
View File
@@ -111,7 +111,9 @@
"section_uuid": "...",
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"staff_uuid": "..."
"staff_uuid": "...",
"insurance_covered": true,
"insurance_price_rials": 200000
}
```
@@ -121,8 +123,10 @@
| name | string | ✅ |
| price_rials | integer | ❌ (پیش‌فرض 0) |
| staff_uuid | UUID | ❌ |
| insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود |
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
**Response 201:** ServiceItem object
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
---
@@ -135,7 +139,9 @@
"name": "رادیوگرافی دیجیتال",
"price_rials": 600000,
"staff_uuid": null,
"active": false
"active": false,
"insurance_covered": true,
"insurance_price_rials": 250000
}
```
@@ -160,3 +166,46 @@
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_SERVICE_ITEM_IN_USE | 409 | سرویس در پرونده بیمار استفاده شده |
---
## تعرفه‌ی نسخه‌دار سالانه (Tariff) — فاز ۳ سیستم صورتحساب
هر خدمت می‌تواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفه‌ی سالی ثبت نشود، به `price_rials` خود خدمت fallback می‌شود (`TariffService::resolvePrice`). سال جاری شمسی سمت سرور با `IntlDateFormatter` (تقویم persian) محاسبه می‌شود.
### GET /api/v1/service-items/{uuid}/tariffs
لیست تعرفه‌های یک خدمت + قیمت پیش‌فرض + سال جاری.
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک خدمت)
```json
{
"success": true,
"data": {
"current_year": 1405,
"default_price_rials": 500000,
"data": [
{ "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
{ "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
]
}
}
```
### PUT /api/v1/service-items/{uuid}/tariffs/{year}
ثبت/به‌روزرسانی تعرفه‌ی یک سال (upsert). `year` بین ۱۳۹۰ تا ۱۵۰۰.
**Body:**
```json
{ "price_rials": 600000 }
```
**Response 200:** `{ success, data: { …tariff } }`
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
+187
View File
@@ -265,3 +265,190 @@ Remove an insurance from a doctor's list.
```json
{ "success": true, "data": { "message": "بیمه از لیست حذف شد" } }
```
---
## EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه
قیمت‌گذاری ویزیت برای entity جاری (پزشک یا کلینیک)، با تفکیک:
- **ویزیت آزاد** (بدون بیمه) — یک مبلغ پایه (ردیفی با `insurance_id = null`)
- **سهم بیمار به ازای هر بیمه** پایه/مکمل
entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR``doctor`، نقش `ROLE_CLINIC``clinic`. ذخیره‌سازی polymorphic در جدول `entity_insurance_pricing` (`entity_type`, `entity_id`, `insurance_id` nullable, `patient_share_rials`).
---
## GET `/api/v1/insurance-pricing`
قیمت‌گذاری بیمه‌ی entity جاری + لیست همه‌ی بیمه‌های فعال (با سهم بیمار اگر تعیین شده).
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Response `200`
```json
{
"success": true,
"data": {
"entity_type": "doctor",
"entity_id": 7,
"free_visit_price_rials": 5000000,
"insurances": [
{
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"type": "basic",
"patient_share_rials": 1500000
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
"patient_share_rials": null
}
]
}
}
```
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.
---
## PUT `/api/v1/insurance-pricing`
ذخیره/به‌روزرسانی قیمت ویزیت آزاد و سهم بیمار هر بیمه. عملیات upsert؛ ردیفی که `patient_share_rials = null` بفرستد حذف می‌شود.
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Request Body
```json
{
"free_visit_price_rials": 5000000,
"insurances": [
{ "insurance_id": 3, "patient_share_rials": 1500000 },
{ "insurance_id": 9, "patient_share_rials": null }
]
}
```
| فیلد | نوع | توضیح |
|------|-----|-------|
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |
### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
---
## TenantInsurance — قراردادهای بیمه‌ی tenant (فاز ۱ سیستم صورتحساب)
قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). tenant از `#[CurrentUser]` (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic). جدول `tenant_insurances`.
### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای فعال tenant جاری.
**Permission:** `AUTH` (doctor/clinic)
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "…",
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"insurance_kind": "basic",
"version": 1,
"is_active": true,
"coverage_percent": 70,
"franchise_rials": 0,
"annual_ceiling_rials": null,
"effective_from": 1718900000,
"effective_to": null
}
]
}
}
```
### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.
**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_id` | int | الزامی |
| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش `coverage_percent` / `franchise_rials` / `annual_ceiling_rials`. فقط قرارداد متعلق به tenant جاری.
### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود.
```json
{ "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } }
```
> **Guard:** `TenantInsuranceService::assertActive()` هنگام پذیرش/صورتحساب فقط بیمه‌های فعالِ همان tenant را مجاز می‌داند؛ در غیر این صورت `422 ERR_VALIDATION_001` («این بیمه برای این کلینیک/پزشک فعال نیست»).
---
## TenantServiceCoverage — پوشش خدمت تحت یک قرارداد بیمه (فاز ۲)
override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فیلدهای `null` از خود قرارداد ارث می‌برند. اگر `covered=false` → آن خدمت تحت آن بیمه پوشش ندارد. جدول `tenant_service_coverage`.
### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
لیست overrideهای پوشش خدمات یک قرارداد.
**Permission:** `AUTH` (مالک قرارداد)
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "…",
"tenant_insurance_id": 4,
"service_item_id": 12,
"covered": true,
"coverage_percent": 80,
"franchise_rials": null,
"ceiling_rials": null
}
]
}
}
```
### PUT `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
تنظیم/به‌روزرسانی پوشش یک خدمت (upsert).
**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `service_item_id` | int | الزامی |
| `covered` | bool | پیش‌فرض true |
| `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد |
| `ceiling_rials` | int \| null | null = ارث از قرارداد |
پاسخ `200`: `{ success, data: { message } }`.
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` service_item_id الزامی.
> منطق resolve: `TenantInsuranceService::coverageRuleForService()` ابتدا override خدمت را بررسی می‌کند؛ اگر `covered=false` → `CoverageRule::notCovered()`؛ در غیر این صورت فیلدهای null از قرارداد پر می‌شوند. این `CoverageRule` در فاز ۴ ورودی `BillingCalculator` است.
@@ -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` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.