- Introduced `tracking_number` field in the `claims` table to store the insurance tracking number. - Created `claim_status_logs` table to maintain a history of status changes for claims, including who made the change and when. - Implemented `ClaimStatusLog` entity and repository for managing status log entries. - Updated `ClaimService` to log transitions and handle tracking numbers during claim submissions. - Added new API endpoint for fetching claims by patient, including detailed claim history and status logs. - Enhanced frontend with a new `ClaimPatientDetailPage` to display claims and their status history. - Added tests to ensure correct aggregation of claims and proper handling of status transitions.
379 lines
20 KiB
Markdown
379 lines
20 KiB
Markdown
# Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
|
||
|
||
> **Prefix:** `/api/v1/billing`
|
||
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
|
||
> tenant از `#[CurrentUser]` با **`App\Patient\Security\PatientRecordScopeResolver`** resolve میشود — همان رزولوری که پروندههای بیمار استفاده میکنند، چون صورتحساب و مطالبه از دل مراجعه بیرون میآیند و باید در همان محیط دیده شوند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها. منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد. جزئیات جدول محیطها: [patient.md](patient.md#record-access-model).
|
||
>
|
||
> پیش از این، این دامنه ترتیب نقشها را خودش پیاده کرده بود و اول `ROLE_DOCTOR` را میگرفت؛ در نتیجه **مالک کلینیکی که خودش پزشک هم هست** به مطب شخصیاش نگاشت میشد و صورتحساب/مطالبهی کلینیک خودش را `404` میگرفت. همین رزولور در `InsuranceController` هم استفاده میشود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
|
||
|
||
صورتحساب (`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:** همان ساختار بالا + کلید `session` — خروجی کامل `PatientSession.toArray()` مراجعهای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختیها، کالای مصرفی، تخفیف، مبالغ پرداختشده). اگر صورتحساب از session ساخته نشده باشد `session: null`.
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "…",
|
||
"total_rials": 600000,
|
||
"status": "finalized",
|
||
"items": [ … ],
|
||
"session": {
|
||
"uuid": "…",
|
||
"session_at": 1718900000,
|
||
"paid_at": 1718990000,
|
||
"services_total_rials": 2400000,
|
||
"consumables_total_rials": 40000,
|
||
"discount_rials": 200000,
|
||
"final_price_rials": 2240000,
|
||
"paid_total_rials": 1500000,
|
||
"payments": [
|
||
{ "uuid": "…", "method": "wallet", "amount_rials": 1500000, "paid_at": 1718990000, "created_by_name": "منشی تست", "created_at": 1718990000 }
|
||
],
|
||
"consumables": [
|
||
{ "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**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.
|
||
|
||
**ساخت خودکار:** هنگام ثبت session بیمهدار (`POST /api/v1/patient/{uuid}/session`)، زنجیرهی صورتحساب→نهاییسازی→مطالبه بهصورت خودکار اجرا میشود؛ نیازی به فراخوانی دستی `POST /billing/claims` نیست. برای هر صورتحساب تنها یکبار مطالبه ساخته میشود (تلاش مجدد `422`).
|
||
|
||
**وضعیتها:** `pending → submitted → {approved → paid | rejected}`. از `paid`/`rejected` خروجی ندارد.
|
||
|
||
## POST /api/v1/billing/claims
|
||
ساخت مطالبات از یک صورتحساب نهاییشده.
|
||
|
||
**Body:** `{ "invoice_uuid": "…" }`
|
||
**Response 201:** `{ success, data: [ …claims ] }` (یک یا دو مطالبه: پایه/مکمل).
|
||
**Errors:** `422` صورتحساب نهایی نشده / سهم بیمه ندارد / مطالبه از قبل ثبت شده · `404` صورتحساب یافت نشد.
|
||
|
||
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
|
||
|
||
## GET /api/v1/billing/claims
|
||
لیست مطالبات tenant با فیلترهای اختیاری (query params):
|
||
|
||
| پارامتر | نوع | توضیح |
|
||
|---------|-----|-------|
|
||
| `status` | string | `pending\|submitted\|approved\|rejected\|paid` |
|
||
| `insurance_id` | int | فقط مطالبات یک بیمهگر |
|
||
| `from` | int (Unix) | مطالبات با `created_at >= from` (شروع بازه) |
|
||
| `to` | int (Unix) | مطالبات با `created_at <= to` (پایان بازه) |
|
||
| `q` | string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) |
|
||
| `page` | int | شماره صفحه (پیشفرض ۱) |
|
||
| `limit` | int | تعداد در هر صفحه (پیشفرض ۵۰، حداکثر ۱۰۰) |
|
||
|
||
> **صفحهبندی:** پاسخ علاوه بر `data.data` (آرایهی مطالبات) یک `data.meta` با `totalRecords`/`totalPages`/`currentPage` دارد. پاکت قبلی (`data.data`) دستنخورده است؛ کلاینتهای موجود بدون تغییر کار میکنند.
|
||
|
||
> بازهی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به `from`/`to` یونیکس تبدیل میشود؛ backend فقط بازهی یونیکس میگیرد. فیلتر `q` از طریق زنجیره `claim → claim_item → invoice_item → invoice → patient_record → user` با `EXISTS` اعمال میشود.
|
||
|
||
هر مطالبه شامل `insurance_name`، `patient_name`، `patient_mobile` و آرایهی `items` با جزئیات هر ردیف است.
|
||
|
||
هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
|
||
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `title` | string \| null | شرح ردیف؛ «ویزیت» یا نام خدمت |
|
||
| `is_visit` | bool | `true` اگر ردیف ویزیت باشد (`service_item_id` تهی) |
|
||
| `quantity` | int | تعداد |
|
||
| `total_rials` | int \| null | مبلغ کل آن ردیف (قیمت کامل، قبل از بیمه) |
|
||
| `visit_date` | int \| null | تاریخ ثبت مراجعه (Unix؛ نمایش شمسی) |
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": { "data": [
|
||
{
|
||
"uuid": "…", "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "insurance_kind": "base",
|
||
"total_claimed_rials": 240000, "status": "pending",
|
||
"items": [
|
||
{ "invoice_item_id": 12, "title": "ویزیت", "is_visit": true, "quantity": 1, "total_rials": 300000, "claimed_rials": 240000, "approved_rials": null, "visit_date": 1718000000 },
|
||
{ "invoice_item_id": 13, "title": "سرم ۵۰۰cc", "is_visit": false, "quantity": 2, "total_rials": 170000, "claimed_rials": 0, "approved_rials": null, "visit_date": 1718000000 }
|
||
]
|
||
}
|
||
] }
|
||
}
|
||
```
|
||
|
||
> این فیلدها با یک کوئری گروهی (`InvoiceItemRepository::detailsForIds` + `PatientSessionRepository::datesForIds`) پر میشوند تا N+1 رخ ندهد. همان enrichment روی پاسخ `POST /claims` و `POST /claims/{uuid}/{action}` هم اعمال میشود.
|
||
|
||
## GET /api/v1/billing/claims/by-patient
|
||
نمای سطحاول داشبورد مطالبات: **یک ردیف بهازای هر بیمار** (نه هر مطالبه)، با جمعهای تجمیعی.
|
||
|
||
**Query params:**
|
||
|
||
| Param | Type | Default | توضیح |
|
||
|-------|------|---------|-------|
|
||
| `page` | int | 1 | شماره صفحه |
|
||
| `limit` | int | 20 | حداکثر ۱۰۰ |
|
||
| `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` |
|
||
| `dir` | string | `desc` | `asc` \| `desc` |
|
||
| `search` | string | — | نام، موبایل یا کد ملی بیمار |
|
||
| `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` |
|
||
| `insurance_id` | int | — | شناسه بیمه |
|
||
| `doctor_id` | int | — | پزشکِ نوبتِ مراجعه |
|
||
| `payment_status` | string | — | `paid` (وصولشده) \| `unpaid` |
|
||
| `from` / `to` | int | — | بازهی `claims.created_at` (unix ثانیه) |
|
||
|
||
پاسخ paginated استاندارد (`{ success, data: [], meta }`):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"patient_uuid": "45064492-...",
|
||
"record_uuid": "ad0a3d0e-...",
|
||
"full_name": "تست جراحی بینی",
|
||
"mobile": "09370671756",
|
||
"national_code": null,
|
||
"claims_count": 2,
|
||
"total_services_rials": 81500000,
|
||
"total_insurance_rials": 57050000,
|
||
"total_patient_rials": 24450000,
|
||
"total_approved_rials": 28000000,
|
||
"total_paid_rials": 28000000,
|
||
"overall_status": "mixed",
|
||
"last_activity_at": 1784404115
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
|
||
- `overall_status`: اگر همهی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`.
|
||
- ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`.
|
||
- **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحسابهای یکتا** جمع میشوند، نه از مطالبات. یک صورتحساب میتواند همزمان مطالبهی پایه و مکمل داشته باشد؛ جمعزدن از سمت مطالبه مبلغ خدمات را دوبار میشمرد.
|
||
- مطالبه لینک مستقیم به بیمار ندارد؛ زنجیرهی `claim → claim_item → invoice_item → invoice → patient_record` است.
|
||
|
||
## GET /api/v1/billing/claims/by-patient/{patientUuid}
|
||
جزئیات کامل مطالبات یک بیمار (`patientUuid` = **uuid پرونده**، همان `record_uuid` نمای سطحاول). فیلترها همان فیلترهای بالا.
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"patient": { "uuid": "...", "record_uuid": "...", "full_name": "...", "mobile": "...", "national_code": null },
|
||
"claims": [
|
||
{
|
||
"uuid": "b244b506-...",
|
||
"invoice_uuid": "15498c14-...",
|
||
"visit_date": 1784440200,
|
||
"doctor_name": "دکتر تست",
|
||
"insurance_id": 176,
|
||
"insurance_name": "تامین اجتماعی",
|
||
"insurance_kind": "base",
|
||
"service_base_rials": 41000000,
|
||
"coverage_percent": 70,
|
||
"insurance_share_rials": 28700000,
|
||
"patient_share_rials": 12300000,
|
||
"total_approved_rials": 28000000,
|
||
"total_paid_rials": 28000000,
|
||
"status": "paid",
|
||
"tracking_number": "TM-4419-88",
|
||
"reject_reason": null,
|
||
"submitted_at": 1784404200,
|
||
"settled_at": 1784404300,
|
||
"created_at": 1784404115,
|
||
"allowed_transitions": [],
|
||
"logs": [
|
||
{ "uuid": "...", "from_status": null, "to_status": "pending", "note": "ایجاد مطالبه", "by": null, "at": 1784404115 },
|
||
{ "uuid": "...", "from_status": "pending", "to_status": "submitted", "note": null, "by": "علی بهروزی", "at": 1784404200 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- `coverage_percent` محاسبهشده است: `insurance_share_rials / service_base_rials × 100`.
|
||
- `allowed_transitions` از `Claim::TRANSITIONS` میآید؛ پنل دکمهها را از همین میسازد و فهرست مجاز را hardcode نمیکند.
|
||
- `logs` تاریخچهی کامل تغییر وضعیت از جدول `claim_status_logs` است (بهترتیب زمانی صعودی).
|
||
|
||
**Errors:** `404` پرونده بیمار یافت نشد یا متعلق به tenant دیگری است · `403` پروفایل یافت نشد.
|
||
|
||
## POST /api/v1/billing/claims/{uuid}/{action}
|
||
انتقال وضعیت. `action` ∈ `submit|approve|reject|pay`. هر انتقال یک ردیف در `claim_status_logs` ثبت میکند (وضعیت مبدأ/مقصد، توضیح، کاربر، زمان).
|
||
|
||
| action | body | اثر |
|
||
|--------|------|-----|
|
||
| submit | `tracking_number` (اختیاری) | pending → submitted؛ شماره پرونده/پیگیری بیمه روی مطالبه ذخیره میشود |
|
||
| approve | `approved_rials` (اختیاری) | submitted → approved (پیشفرض = کل ادعا) — باید `0 ≤ approved_rials ≤ total_claimed_rials` |
|
||
| reject | `reason` (**الزامی**) | submitted → rejected |
|
||
| pay | `paid_rials` (اختیاری) | approved → paid (پیشفرض = approved) — باید `0 ≤ paid_rials ≤ total_approved_rials` |
|
||
|
||
`note` (اختیاری) روی همهی اکشنها پذیرفته میشود و در تاریخچه ثبت میگردد؛ برای `reject` در نبودِ `note` خودِ `reason` ثبت میشود.
|
||
|
||
**Errors:** `422` انتقال نامعتبر، دلیل رد خالی، یا مبلغ `approved_rials`/`paid_rials` خارج از بازه (`field` در پاسخ) · `404` مطالبه یافت نشد.
|
||
|
||
> اعتبارسنجی انتقال سمت **سرور** انجام میشود (`Claim::TRANSITIONS` منبع حقیقت است)؛ مخفیکردن دکمه در UI کافی نیست.
|
||
|
||
## GET /api/v1/billing/reports/insurance-debt
|
||
گزارش بدهی بیمهها برای tenant (group بر اساس بیمه).
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": [
|
||
{ "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "claimed": 840000, "approved": 800000, "paid": 500000, "debt": 340000 }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
`debt = claimed - paid` (حداقل صفر).
|
||
|
||
## GET /api/v1/my/billing/payments
|
||
«لیست پرداختها» — فهرست مسطح صورتحسابهای ثبتشدهی همان tenant (هر ردیف یک صورتحساب)، جدیدترین اول. فقط `finalized`/`paid`؛ `draft`/`void` نادیده گرفته میشوند.
|
||
|
||
**Query params:**
|
||
|
||
| param | توضیح |
|
||
|-------|-------|
|
||
| `national_code` | جستوجوی جزئی روی کد ملی بیمار (`LIKE`) |
|
||
| `status` | `paid` (پرداختشده) · `unsettled` (تسویهنشده = `finalized`) |
|
||
| `from` / `to` | بازهی `issued_at` بر حسب ثانیهی Unix |
|
||
| `page` / `limit` | صفحهبندی (پیشفرض ۱ / ۲۰، سقف ۱۰۰) |
|
||
|
||
**Response 200** (صفحهبندیشدهی مسطح):
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{ "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی",
|
||
"national_code": "1744023654", "issued_at": 1717000000,
|
||
"amount_rials": 2350000, "status": "paid" }
|
||
],
|
||
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب. `status` دو حالته: `paid` یا `unsettled`.
|
||
|
||
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
|
||
|
||
## GET /api/v1/my/billing/patients/{patientUuid}/invoices
|
||
«پرداختهای ثبتشده» — سربرگ بیمار + فهرست صفحهبندیشدهی صورتحسابهای `finalized`/`paid` او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد.
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" },
|
||
"data": [
|
||
{ "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000,
|
||
"status": "paid", "service_title": "روکش دندان",
|
||
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ] }
|
||
],
|
||
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
}
|
||
```
|
||
> `status` دو حالته: `paid` (پرداختشده) یا `unsettled` (تسویهنشده = `finalized`). `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
|
||
|
||
**Errors:** `403` پروفایل tenant یافت نشد · `404` (`ERR_NOT_FOUND_001`) بیمار متعلق به این tenant نیست/یافت نشد.
|
||
|
||
---
|
||
|
||
## ارسال مطالبه (ClaimSubmitter)
|
||
|
||
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام میشود. پیادهسازی پیشفرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمیکند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف میشود.
|
||
|
||
## جریان صدور فاکتور در پنل ادمین
|
||
|
||
«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از `useIssueInvoice` استفاده میکنند: `POST /billing/invoices` (idempotent) و سپس `finalize` اگر `draft` باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.
|