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.
This commit is contained in:
+110
-9
@@ -2,7 +2,9 @@
|
||||
|
||||
> **Prefix:** `/api/v1/billing`
|
||||
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
|
||||
> tenant از `#[CurrentUser]` resolve میشود (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic، و `ROLE_SECRETARY`→ همان مطب/کلینیکِ فعال بر اساس `db_uuid` و رابطهی فعالِ منشی). یعنی منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد.
|
||||
> tenant از `#[CurrentUser]` با **`App\Patient\Security\PatientRecordScopeResolver`** resolve میشود — همان رزولوری که پروندههای بیمار استفاده میکنند، چون صورتحساب و مطالبه از دل مراجعه بیرون میآیند و باید در همان محیط دیده شوند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها. منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد. جزئیات جدول محیطها: [patient.md](patient.md#record-access-model).
|
||||
>
|
||||
> پیش از این، این دامنه ترتیب نقشها را خودش پیاده کرده بود و اول `ROLE_DOCTOR` را میگرفت؛ در نتیجه **مالک کلینیکی که خودش پزشک هم هست** به مطب شخصیاش نگاشت میشد و صورتحساب/مطالبهی کلینیک خودش را `404` میگرفت. همین رزولور در `InsuranceController` هم استفاده میشود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
|
||||
|
||||
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با `BillingCalculator` محاسبه میشود:
|
||||
|
||||
@@ -188,18 +190,117 @@
|
||||
|
||||
> این فیلدها با یک کوئری گروهی (`InvoiceItemRepository::detailsForIds` + `PatientSessionRepository::datesForIds`) پر میشوند تا N+1 رخ ندهد. همان enrichment روی پاسخ `POST /claims` و `POST /claims/{uuid}/{action}` هم اعمال میشود.
|
||||
|
||||
## POST /api/v1/billing/claims/{uuid}/{action}
|
||||
انتقال وضعیت. `action` ∈ `submit|approve|reject|pay`.
|
||||
## GET /api/v1/billing/claims/by-patient
|
||||
نمای سطحاول داشبورد مطالبات: **یک ردیف بهازای هر بیمار** (نه هر مطالبه)، با جمعهای تجمیعی.
|
||||
|
||||
| action | body اختیاری | اثر |
|
||||
|--------|--------------|-----|
|
||||
| submit | — | 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` |
|
||||
**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 بر اساس بیمه).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user