Files
clinicpro/docs/api/billing.md
T
hamed 20bdc49e89 feat(claims): add tracking number and status history for claims
- 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.
2026-07-18 23:38:02 +03:30

379 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.