- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials. - Modified Appointment entity to carry supplementary insurance ID alongside base insurance. - Implemented SessionBillingService to ensure finalized invoices for insured patient sessions. - Created InvoiceFinalized event to trigger claims creation upon invoice finalization. - Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims. - Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
396 lines
22 KiB
Markdown
396 lines
22 KiB
Markdown
# سیستم صورتحساب و بیمه (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` تبدیل میشود؛ تا آن زمان بهعنوان نسخهی سادهی موقت کار میکند.
|