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:
hamed
2026-07-18 23:38:02 +03:30
parent b3a5cda808
commit 20bdc49e89
15 changed files with 1412 additions and 309 deletions
+110 -9
View File
@@ -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 بر اساس بیمه).