Refactor insurance share calculation logic in PatientService
- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator. - Introduced new fields in PatientSession to store breakdown of insurance shares and patient share. - Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard. - Added migration to backfill existing sessions with appropriate values for the new fields. - Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total. - Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
# بازطراحی صفحه Claims به داشبورد مدیریتی بیمار-محور
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend Symfony + پنل ادمین React)
|
||||
|
||||
پیشنیاز: `clinicpro/.claude/prompt/insurance-shared-calculation.md` باید **قبل** از این پرامپت اجرا شود — ستونهای سهم بیمه/بیمار روی session و منطق واحد محاسبه از آنجا میآید.
|
||||
|
||||
## زمینه
|
||||
|
||||
صفحه `/admin/claims` امروز یک لیست کارتی از رکوردهای Claim است: هر Claim یک کارت، و داخل هر کارت یک `<table>` تودرتو از اقلام. این نه DataTable استاندارد پروژه است و نه برای پیگیری پروندههای بیمهی یک بیمار کاربردی — کاربر نمیتواند ببیند «بیمار X مجموعاً چقدر ادعا دارد و چقدر وصول شده».
|
||||
|
||||
## هدف
|
||||
|
||||
تبدیل صفحه به داشبورد دو سطحی:
|
||||
- **سطح ۱ — لیست بیماران:** هر ردیف یک بیمار با تجمیع مبالغ و وضعیت کلی.
|
||||
- **سطح ۲ — جزئیات یک بیمار:** همهی درخواستهای بیمهی آن بیمار با جزئیات کامل و لاگ تغییرات.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/pages/ClaimsPage.tsx` | صفحه فعلی (۳۶۶ خط) — بازنویسی میشود |
|
||||
| `assets/admin/App.tsx:237` | ثبت route `claims`؛ نقشهای `['doctor','clinic']` + `blockClinicScope` + `<FeatureGate feature="insurance">` |
|
||||
| `src/Billing/Controller/BillingController.php:252` | `GET /api/v1/billing/claims` |
|
||||
| `src/Billing/Controller/BillingController.php:286` | `POST /api/v1/billing/claims/{uuid}/{submit\|approve\|reject\|pay}` |
|
||||
| `src/Billing/Controller/BillingController.php:332` | `GET /api/v1/billing/reports/insurance-debt` |
|
||||
| `src/Billing/Entity/Claim.php` | `insuranceId`, `insuranceKind`, `totalClaimedRials`, `totalApprovedRials`, `totalPaidRials`, `status`, `TRANSITIONS` (`:26`), `rejectReason`, `submittedAt`, `settledAt` |
|
||||
| `src/Billing/Entity/ClaimItem.php` | `invoiceItemId`, `claimedRials` |
|
||||
| `src/Billing/Entity/Invoice.php` / `InvoiceItem.php` | مبالغ اصلی و سهمها |
|
||||
| `src/Billing/Service/ClaimService.php` | `buildClaim()` و انتقال وضعیت |
|
||||
| `assets/admin/components/ui/` | `DataTable`, `Pagination`, `SearchableSelect`, `PageHeader`, `Modal`, `StatCard`, `StatusBadge`, `PersianDatePicker`, `FeatureGate` |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`ClaimsPage.tsx` — کارت بهازای هر Claim، totalها بهصورت یک رشته متنی درهم (`:271-276`):
|
||||
|
||||
```
|
||||
ادعا … · تأیید … · پرداخت … · دلیل رد: …
|
||||
```
|
||||
|
||||
و جدول داخلی (`:302-337`):
|
||||
|
||||
```tsx
|
||||
<thead>
|
||||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
||||
<th>شرح</th><th>تاریخ مراجعه</th>
|
||||
<th style={{ textAlign: 'center' }}>تعداد</th>
|
||||
<th style={{ textAlign: 'left' }}>مبلغ کل</th>
|
||||
<th style={{ textAlign: 'left' }}>سهم بیمه (ادعا)</th>
|
||||
<th style={{ textAlign: 'left' }}>تأییدشده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
مشکلات موجود که باید در بازطراحی رفع شوند:
|
||||
- `GET /api/v1/billing/reports/insurance-debt` **دو بار** با دو query key جدا fetch میشود (`:116`, `:122`) — یکی برای پنل بدهی، یکی برای پر کردن dropdown بیمه.
|
||||
- فیلترها با URL همگام نیستند (همه `useState`)؛ رفرش صفحه همه فیلترها را پاک میکند.
|
||||
- وضعیتها در `STATUS_META` محلی تعریف شدهاند (`:69-75`) بهجای `StatusBadge`.
|
||||
- بدون `DoctorClaimsPage.tsx` اشتباه گرفته نشود — آن صفحه (`/admin/doctor-claims`) مربوط به ادعای مالکیت پروفایل پزشک است، نه بیمه.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Backend — endpoint تجمیع بیمار-محور
|
||||
|
||||
endpoint جدید:
|
||||
|
||||
```
|
||||
GET /api/v1/billing/claims/by-patient
|
||||
```
|
||||
|
||||
پارامترها: `page`, `limit`, `search` (نام/موبایل/کد ملی), `insurance_id`, `doctor_id`, `clinic_id`, `status`, `payment_status`, `from`, `to` (Unix), `sort`, `dir`.
|
||||
|
||||
پاسخ paginated (`$this->paginated()`) با هر آیتم:
|
||||
|
||||
```json
|
||||
{
|
||||
"patient_uuid": "...",
|
||||
"full_name": "...",
|
||||
"mobile": "...",
|
||||
"national_code": "...",
|
||||
"claims_count": 0,
|
||||
"total_services_rials": 0,
|
||||
"total_insurance_rials": 0,
|
||||
"total_patient_rials": 0,
|
||||
"total_approved_rials": 0,
|
||||
"total_paid_rials": 0,
|
||||
"overall_status": "pending|submitted|approved|rejected|paid|mixed",
|
||||
"last_activity_at": 0
|
||||
}
|
||||
```
|
||||
|
||||
قاعده `overall_status`: اگر همه Claimهای بیمار یک وضعیت دارند همان؛ در غیر این صورت `mixed`.
|
||||
|
||||
پیادهسازی با DQL و array hydration (`->getArrayResult()`)، تجمیع در SQL (`GROUP BY patient`) — نه در PHP روی کل رکوردها.
|
||||
|
||||
### ۲. Backend — endpoint جزئیات یک بیمار
|
||||
|
||||
```
|
||||
GET /api/v1/billing/claims/by-patient/{patientUuid}
|
||||
```
|
||||
|
||||
لیست همه Claimهای آن بیمار (paginated)، هر رکورد شامل:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"visit_date": 0,
|
||||
"doctor_name": "...",
|
||||
"clinic_name": "...",
|
||||
"service_title": "...",
|
||||
"service_base_rials": 0,
|
||||
"coverage_percent": 0,
|
||||
"coverage_rials": 0,
|
||||
"patient_share_rials": 0,
|
||||
"insurance_share_rials": 0,
|
||||
"insurance_title": "...",
|
||||
"insurance_kind": "base|supplementary",
|
||||
"status": "...",
|
||||
"submitted_at": 0,
|
||||
"settled_at": 0,
|
||||
"tracking_number": null,
|
||||
"reject_reason": null,
|
||||
"description": null,
|
||||
"allowed_transitions": ["submit"],
|
||||
"logs": [
|
||||
{ "at": 0, "from": "pending", "to": "submitted", "by": "نام کاربر", "note": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`allowed_transitions` از `Claim::TRANSITIONS` بیاید تا فرانت دکمههای مجاز را خودش hardcode نکند.
|
||||
|
||||
### ۳. Backend — شماره پیگیری و لاگ تغییرات
|
||||
|
||||
دو مورد در دیتابیس وجود ندارند و باید اضافه شوند:
|
||||
|
||||
**الف) `trackingNumber`** روی `Claim` (string nullable) — شماره پرونده/پیگیری بیمه. هنگام `submit` قابل ورود باشد و در `POST /api/v1/billing/claims/{uuid}/submit` بهعنوان فیلد اختیاری بدنه پذیرفته شود.
|
||||
|
||||
**ب) `ClaimStatusLog`** — entity جدید:
|
||||
|
||||
| فیلد | نوع |
|
||||
|------|-----|
|
||||
| `claimId` | int |
|
||||
| `fromStatus` | string nullable |
|
||||
| `toStatus` | string |
|
||||
| `note` | text nullable |
|
||||
| `createdBy` | userId |
|
||||
| `createdAt` | Unix int |
|
||||
|
||||
در `ClaimService` هر جا انتقال وضعیت انجام میشود یک رکورد لاگ نوشته شود. migration لازم است:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console make:migration
|
||||
ddev exec php bin/console doctrine:migrations:migrate -n
|
||||
```
|
||||
|
||||
**Backfill:** برای Claimهای موجود از `submittedAt` / `settledAt` رکوردهای لاگ تقریبی ساخته شود تا صفحه جزئیات برای داده قدیمی خالی نباشد.
|
||||
|
||||
### ۴. Frontend — سطح ۱: DataTable بیماران
|
||||
|
||||
`ClaimsPage.tsx` بازنویسی شود با `DataTable` استاندارد پروژه (نه کارت):
|
||||
|
||||
ستونها: نام بیمار | موبایل | کد ملی | تعداد درخواست | مجموع خدمات | مجموع سهم بیمه | مجموع سهم بیمار | وضعیت کلی (`StatusBadge`)
|
||||
|
||||
بالای جدول: ردیف `StatCard` با مجموع کل خدمات / کل سهم بیمه / وصولشده / مانده وصولنشده — از `GET /api/v1/billing/reports/insurance-debt` که باید **فقط یک بار** fetch شود (یک `useQuery`، خروجیاش هم پنل و هم options فیلتر بیمه را بدهد).
|
||||
|
||||
کلیک روی ردیف → سطح ۲.
|
||||
|
||||
### ۵. Frontend — سطح ۲: جزئیات بیمار
|
||||
|
||||
مسیر جدید `/admin/claims/:patientUuid` در `App.tsx` با همان roleها و `<FeatureGate feature="insurance">`.
|
||||
|
||||
- `PageHeader` با breadcrumb: پروندههای بیمه ← نام بیمار.
|
||||
- `DataTable` از درخواستهای بیمه با ستونهای: تاریخ مراجعه | پزشک | کلینیک | سرویس | مبلغ اصلی | پوشش بیمه (درصد + مبلغ) | سهم بیمار | سهم بیمه | وضعیت | تاریخ ارسال | تاریخ پرداخت | شماره پیگیری.
|
||||
- `actions?(row)` → دکمههای انتقال وضعیت بر اساس `allowed_transitions`.
|
||||
- کلیک روی ردیف → `Modal` (size `lg`) با توضیحات کامل + **لاگ کامل تغییرات** بهصورت timeline.
|
||||
- عملیات `reject` باید `reject_reason` بگیرد؛ `submit` باید `tracking_number` اختیاری بگیرد.
|
||||
|
||||
### ۶. Frontend — فیلتر، جستجو، مرتبسازی، URL sync
|
||||
|
||||
فیلترها در هر دو سطح: بیمار (فقط سطح ۱) | بیمه | پزشک | کلینیک | وضعیت Claim | بازه زمانی (`PersianDatePicker` + میانبر یک ماه/یک سال اخیر) | وضعیت پرداخت.
|
||||
|
||||
همهی فیلترها + `page` + `sort`/`dir` باید در **query string آدرس** ذخیره شوند (`useSearchParams`) تا رفرش و اشتراکگذاری لینک کار کند. این رفع مشکل فعلی است.
|
||||
|
||||
مرتبسازی سرور-ساید از طریق `sort`/`dir` روی: نام بیمار، تعداد درخواست، مجموع خدمات، مجموع سهم بیمه، آخرین فعالیت.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الزامی:** صفحه جدید با تم، Layout و کامپوننتهای موجود ساخته شود — بدون طراحی جدید. از `components/ui/` استفاده شود، نه جدول دستساز.
|
||||
- برای dropdownها **همیشه** `SearchableSelect`، هرگز `<select>` نیتیو.
|
||||
- `StatusBadge` فعلاً `type` برای `claim` ندارد — نوع جدید `claim` با نگاشت pending=خاکستری، submitted=آبی، approved=کهربایی، rejected=قرمز، paid=سبز، mixed=بنفش اضافه شود و `STATUS_META` محلی حذف شود.
|
||||
- استایل: از design tokenها (`var(--text-3)`, `.card`, `.btn primary sm`, `.badge`) استفاده شود. هگز hardcode ممنوع.
|
||||
- پاسخ paginated: items از `data?.data`، total از `data?.meta?.totalRecords`. single: `data?.data` (گاهی double-nested).
|
||||
- تاریخها Unix timestamp صحیح؛ تبدیل بازه با `toUnix` / `endOfDayUnix` مثل کد فعلی (`ClaimsPage.tsx:21-30`)؛ نمایش شمسی با `formatDate` / `formatDateTime`.
|
||||
- مبالغ با `formatRial`؛ مرز ریال/تومان رعایت شود.
|
||||
- همه controllerها از `BaseController`؛ پاسخها با `$this->paginated()` / `$this->success()` / `$this->error()`.
|
||||
- انتقال وضعیت غیرمجاز باید سمت سرور هم reject شود (`Claim::TRANSITIONS` منبع حقیقت است) — اتکا به مخفیکردن دکمه در UI کافی نیست.
|
||||
- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
|
||||
- بعد از تغییر API، `clinicpro/docs/api/` بهروز شود.
|
||||
|
||||
## تست پذیرش
|
||||
|
||||
۱. `/admin/claims` لیست بیماران با تجمیع درست نمایش میدهد؛ جمع ستون سهم بیمه با `insurance-debt` همخوان است.
|
||||
۲. کلیک روی بیمار → صفحه جزئیات با همهی رکوردهای بیمهی همان بیمار.
|
||||
۳. انتقال وضعیت یک Claim (submit با شماره پیگیری، سپس approve، سپس pay) → لاگ در timeline ثبت میشود.
|
||||
۴. reject بدون دلیل → خطا؛ با دلیل → ثبت و نمایش دلیل.
|
||||
۵. فیلتر بازه زمانی + بیمه اعمال شود، صفحه رفرش شود → فیلترها از URL بازیابی میشوند.
|
||||
۶. کاربر بدون فیچر `insurance` → صفحه در دسترس نیست (`FeatureGate`).
|
||||
۷. مبالغ سهم بیمه/بیمار در این صفحه با صفحه پرداخت و مودال فاکتور **دقیقاً** یکی است.
|
||||
@@ -0,0 +1,202 @@
|
||||
# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و Claim
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend Symfony + پنل ادمین React)
|
||||
|
||||
پرامپت همتا: `clinicpro/.claude/prompt/claims-dashboard-redesign.md` (بازطراحی صفحه `/admin/claims`) — **اول این پرامپت اجرا شود**، چون داشبورد Claims به فیلدهای محاسباتی این پرامپت وابسته است.
|
||||
|
||||
## زمینه
|
||||
|
||||
در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` یک سرویس دارای پوشش بیمه ساخته شده (`/admin/clinic-services/f3e46236-7ddd-49d0-a725-d731c74c24f7`) و برای بیمار `ad0a3d0e-5514-462c-9fcf-20748c1c5e46` ثبت شده است. در صفحه تکمیل پرداخت
|
||||
`/admin/patients/ad0a3d0e-5514-462c-9fcf-20748c1c5e46/session/4f66d5c0-028f-424f-bec7-a10857f11c04/pay`
|
||||
پوشش بیمه اعمال نمیشود و مبلغ قابل پرداخت بیمار برابر کل مبلغ سرویس نمایش داده میشود.
|
||||
|
||||
ریشه مشکل: **دو مسیر محاسباتی مستقل** وجود دارد و `PatientSession` هیچ ستونی برای سهم بیمه ندارد؛ بنابراین breakdown بیمه فقط بعد از ساخت `Invoice` وجود دارد و صفحه پرداخت اصلاً آن را نمیبیند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. حذف محاسبه inline ویزیت در `PatientService::calculateFinalPrice()` و یکیکردن همهی محاسبات روی `BillingCalculator`.
|
||||
۲. ذخیره breakdown بیمه روی `PatientSession` تا صفحه پرداخت، فاکتور، سهم بیمار/بیمه، مانده و وضعیت پرداخت همگی از یک مقدار بخوانند.
|
||||
۳. نمایش سهم بیمه پایه/تکمیلی در صفحه پرداخت.
|
||||
۴. رفع ناسازگاریهای فرمول مانده و over-payment guard.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Billing/Service/BillingCalculator.php` | تنها منبع درست محاسبه سهمها (percent + franchise + ceiling) |
|
||||
| `src/Billing/ValueObject/Money.php` | VO پول؛ `sub()` در صفر clamp میشود |
|
||||
| `src/Insurance/Service/TenantInsuranceService.php` | `coverageRule()` و `coverageRuleForService()` — resolve قرارداد + override سرویس |
|
||||
| `src/Insurance/Entity/TenantInsurance.php` | قرارداد: `coveragePercent`, `franchiseRials`, `annualCeilingRials`, `isActive`, `effectiveFrom/To` |
|
||||
| `src/Insurance/Entity/TenantServiceCoverage.php` | override به ازای (قرارداد، serviceItem): `covered`, `coveragePercent`, `franchiseRials`, `ceilingRials` (null = ارث از قرارداد) |
|
||||
| `src/ClinicService/Entity/ServiceItem.php` | `insuranceCovered` (گیت bool)، `priceRials`، `insurancePriceRials` (فعلاً dead data) |
|
||||
| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()`، `recomputeSettlement()`، `addSessionPayment()`، `updatePayment()` |
|
||||
| `src/Patient/Entity/PatientSession.php` | `finalPriceRials`, `servicesTotalRials`, `discountRials`, `getPaidTotalRials()`, `getRemainingRials()` |
|
||||
| `src/Billing/Service/InvoiceService.php` | ساخت فاکتور از session |
|
||||
| `src/Patient/Controller/PatientController.php` | `POST /api/v1/session/{uuid}/payments` و لیست sessionها |
|
||||
| `assets/admin/components/session/PaymentStep.tsx` | UI صفحه پرداخت (مشترک با `NewSessionPage`) |
|
||||
| `assets/admin/components/InvoiceSummaryModal.tsx` | مودال فاکتور بیمار |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`src/Billing/Service/BillingCalculator.php` (منطق درست):
|
||||
|
||||
```php
|
||||
$baseShare = $total->percent($base->coveragePercent);
|
||||
if ($base->ceilingRials !== null) $baseShare = $baseShare->min(new Money($base->ceilingRials));
|
||||
$remaining = $total->sub($baseShare);
|
||||
// تکمیلی روی باقیمانده اعمال میشود، نه روی کل
|
||||
$suppShare = $remaining->percent($supplementary->coveragePercent);
|
||||
...
|
||||
$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
|
||||
$patient = $remaining->add($franchise)->min($total);
|
||||
```
|
||||
|
||||
`src/Patient/Service/PatientService.php::calculateFinalPrice()` — سرویسها از `BillingCalculator` میآیند اما **ویزیت inline حساب میشود** و آن هم از درصدهای ذخیرهشده روی session، نه از قرارداد:
|
||||
|
||||
```php
|
||||
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
|
||||
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
|
||||
$visitShare = (int) round($afterSupp);
|
||||
```
|
||||
|
||||
`assets/admin/components/session/PaymentStep.tsx:117-122` — کل محاسبه سمت کلاینت:
|
||||
|
||||
```ts
|
||||
const finalPrice = session.final_price_rials ?? 0;
|
||||
const discountRials = session.discount_rials ?? 0;
|
||||
const payable = Math.max(0, finalPrice - discountRials);
|
||||
```
|
||||
|
||||
هیچ فیلد `base_insurance_rials` / `supplementary_rials` در پاسخ session وجود ندارد، پس سهم بیمه اصلاً قابل نمایش نیست.
|
||||
|
||||
`InvoiceSummaryModal.tsx:66-73` — مانده در شاخهی session سهم بیمه را نادیده میگیرد:
|
||||
|
||||
```ts
|
||||
const remaining = session
|
||||
? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
|
||||
: inv ? (paid ? 0 : inv.patient_rials) : 0;
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. دیباگ اولیه: چرا پوشش بیمه اعمال نشده؟
|
||||
|
||||
قبل از هر تغییر کد، با داده واقعی بررسی کن (روی ddev):
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console dbal:run-sql "SELECT id, insurance_covered, price_rials, insurance_price_rials FROM service_item WHERE uuid = 'f3e46236-7ddd-49d0-a725-d731c74c24f7'"
|
||||
ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_insurance WHERE entity_type='clinic' AND is_active=1"
|
||||
ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_service_coverage"
|
||||
ddev exec php bin/console dbal:run-sql "SELECT uuid, insurance_base_id, insurance_supplementary_id, services_total_rials, final_price_rials, discount_rials FROM patient_session WHERE uuid = '4f66d5c0-028f-424f-bec7-a10857f11c04'"
|
||||
```
|
||||
|
||||
سه fail-point محتمل را مشخص کن و در گزارش بنویس کدامیک بوده است:
|
||||
- `service_item.insurance_covered = 0` → گیت بسته است.
|
||||
- `patient_session.insurance_base_id = NULL` → بیمه هنگام ثبت سرویس به session نچسبیده (احتمالاً UI ثبت سرویس بیمه بیمار را ارسال نمیکند).
|
||||
- `tenant_insurance` برای این کلینیک وجود ندارد یا `effective_from/to` بازهی تاریخ session را پوشش نمیدهد.
|
||||
|
||||
اگر fail-point «بیمه به session نچسبیده» بود، مسیر ثبت سرویس برای بیمار را هم اصلاح کن تا `insurance_base_id`/`insurance_supplementary_id` از بیمهی ثبتشدهی بیمار پر شود.
|
||||
|
||||
### ۲. یکیکردن محاسبه ویزیت
|
||||
|
||||
در `PatientService::calculateFinalPrice()` محاسبه inline ویزیت را حذف کن و مثل خطوط سرویس از `TenantInsuranceService::coverageRule()` + `BillingCalculator::calculateItem()` استفاده کن — دقیقاً همان چیزی که `InvoiceService.php:48-51` انجام میدهد.
|
||||
|
||||
فیلدهای `base_insurance_discount_percent` / `supplementary_discount_percent` روی session را بهعنوان **snapshot** نگه دار (backward compat) اما دیگر ورودی محاسبه نباشند؛ بعد از محاسبه از روی درصدهای قرارداد پرشان کن.
|
||||
|
||||
### ۳. ذخیره breakdown بیمه روی PatientSession
|
||||
|
||||
سه ستون جدید به `PatientSession` اضافه کن (nullable-not، default 0):
|
||||
|
||||
- `baseInsuranceRials`
|
||||
- `supplementaryInsuranceRials`
|
||||
- `patientShareRials`
|
||||
|
||||
قرارداد: `patientShareRials` همان چیزی است که `finalPriceRials` باید باشد (سهم بیمار **قبل** از تخفیف دستی). یعنی:
|
||||
|
||||
```
|
||||
servicesTotalRials = مجموع مبلغ اصلی همه اقلام (ویزیت + سرویسها)
|
||||
baseInsuranceRials + supplementaryInsuranceRials + patientShareRials = servicesTotalRials
|
||||
finalPriceRials = patientShareRials
|
||||
payable = finalPriceRials - discountRials
|
||||
remaining = max(0, payable - paidTotal)
|
||||
```
|
||||
|
||||
هر جا session ذخیره یا بازمحاسبه میشود این سه ستون هم نوشته شوند. migration لازم است:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console make:migration
|
||||
ddev exec php bin/console doctrine:migrations:migrate -n
|
||||
```
|
||||
|
||||
**Backfill:** برای sessionهای موجود، مقدار `patientShareRials = finalPriceRials` و دو ستون بیمه = 0 ست شود تا رفتار قدیمی نشکند.
|
||||
|
||||
### ۴. حذف تکرار فرمول مانده و over-payment guard
|
||||
|
||||
- `PatientService::updatePayment()` (حدود `:419-421`) که `payable = finalPrice - discount` را inline دوباره میسازد را حذف کن و از `PatientSession::getRemainingRials()` استفاده کن — همان چیزی که `addSessionPayment()` (`:570`) استفاده میکند.
|
||||
- در `updatePayment` هنگام ویرایش یک پرداخت موجود، مبلغ همان پرداخت باید از `paidTotal` کسر شود وگرنه ویرایش به سمت بالا اشتباهاً reject میشود. این edge case را تست کن.
|
||||
|
||||
### ۵. خروجی API
|
||||
|
||||
در `toArray()` مربوط به session (مسیر `GET /api/v1/patient/{uuid}/sessions` و پاسخهای `POST/PATCH /api/v1/session/{uuid}/...`) این فیلدها اضافه شوند:
|
||||
|
||||
```json
|
||||
{
|
||||
"services_total_rials": 0,
|
||||
"base_insurance_rials": 0,
|
||||
"supplementary_insurance_rials": 0,
|
||||
"patient_share_rials": 0,
|
||||
"final_price_rials": 0,
|
||||
"discount_rials": 0,
|
||||
"paid_total_rials": 0,
|
||||
"remaining_rials": 0,
|
||||
"insurance_base_title": null,
|
||||
"insurance_supplementary_title": null
|
||||
}
|
||||
```
|
||||
|
||||
`remaining_rials` را سرور بدهد تا کلاینت دیگر مانده را خودش نسازد.
|
||||
|
||||
### ۶. UI صفحه پرداخت
|
||||
|
||||
در `assets/admin/components/session/PaymentStep.tsx`:
|
||||
|
||||
- به بخش خلاصه مبالغ (`:196-209`) این ردیفها اضافه شود، **فقط وقتی مقدارشان > 0 است**:
|
||||
- `سهم بیمه پایه` (+ نام بیمه)
|
||||
- `سهم بیمه تکمیلی`
|
||||
- `سهم بیمار`
|
||||
- `payable` دیگر client-side ساخته نشود؛ از `remaining_rials` سرور استفاده شود.
|
||||
- ترتیب نمایش: هزینه کل خدمات → سهم بیمه پایه → سهم بیمه تکمیلی → سهم بیمار → تخفیف → مبلغ نهایی قابل پرداخت → پرداختشده → مانده.
|
||||
|
||||
**احتیاط:** این کامپوننت با `NewSessionPage` (ویزارد ۳ مرحلهای) مشترک است — هر دو مسیر باید تست شوند.
|
||||
|
||||
### ۷. اصلاح مودال فاکتور
|
||||
|
||||
در `assets/admin/components/InvoiceSummaryModal.tsx`:
|
||||
|
||||
- دو شاخهی واگرای «با session» و «بدون session» را یکی کن؛ هر دو باید ستونهای یکسان نشان دهند: جمع خدمات / سهم بیمه پایه / سهم بیمه تکمیلی / سهم بیمار / تخفیف / مبلغ نهایی / پرداختشده / مانده.
|
||||
- `remaining` را از `remaining_rials` سرور بگیر، نه از فرمول محلی.
|
||||
- منطق «وضعیت واقعی پرداخت مستقل از وضعیت فریزشده فاکتور» (تسویهشده اگر مانده صفر) عمداً وجود دارد — حفظش کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- تکمیلی روی **باقیمانده بعد از پایه** اعمال میشود، نه روی کل. این قاعده در `BillingCalculator` درست است و نباید تغییر کند.
|
||||
- `ServiceItem.insurancePriceRials` فعلاً write-only است و هیچ محاسبهای نمیخواندش. یا آن را بهعنوان «مبلغ ثابت پوشش» وارد `BillingCalculator` کن (اولویت بالاتر از percent) یا از UI و `toArray()` حذفش کن — تصمیم را در گزارش بنویس. حالت نصفهکاره نگهداشتنش قابل قبول نیست.
|
||||
- `annualCeilingRials` امروز بهصورت **سقف هر قلم** اعمال میشود در حالی که نامش سقف سالانه است. انباشت سالانهای در کد نیست. رفتار فعلی را تغییر نده اما در کامنت و در گزارش صریح ذکرش کن.
|
||||
- مرز ریال/تومان: ورودیهای UI توماناند، API ریال. از `tomanToRial` / `rialToToman` در `lib/utils.ts` استفاده شود (`RIAL_PER_TOMAN = 10`).
|
||||
- `Money::sub()` در صفر clamp میشود و مقدار منفی نمیپذیرد — روی مبالغ سهمها به آن تکیه کن، `max(0, ...)` دستی ننویس.
|
||||
- قیمت سرویس در session هرچه caller بفرستد ذخیره میشود، ولی فاکتور دوباره از `TariffService::resolvePrice()` برای سال جلالی جاری resolve میکند. این واگرایی را حل کن: session هم باید از `TariffService` قیمت بگیرد.
|
||||
- همه controllerها از `BaseController` ارث میبرند؛ پاسخها با `$this->success()` / `$this->paginated()` / `$this->error()`.
|
||||
- تاریخها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
|
||||
- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
|
||||
- بعد از تغییر API، فایلهای مربوطه در `clinicpro/docs/api/` بهروز شوند.
|
||||
|
||||
## تست پذیرش
|
||||
|
||||
۱. سرویس `f3e46236-...` برای بیمار `ad0a3d0e-...` ثبت شود؛ در صفحه `/pay` باید سهم بیمه پایه و سهم بیمار جدا نمایش داده شوند و مبلغ قابل پرداخت = سهم بیمار باشد.
|
||||
۲. همان session → مودال فاکتور: اعداد باید **دقیقاً** با صفحه پرداخت یکی باشند.
|
||||
۳. پرداخت جزئی ثبت شود → مانده در هر دو صفحه یکسان کم شود.
|
||||
۴. پرداخت کامل → وضعیت در هر دو جا «تسویه شده».
|
||||
۵. سرویسی بدون پوشش بیمه → سهم بیمه ۰، رفتار قبلی بدون تغییر.
|
||||
۶. ویرایش یک پرداخت موجود به مبلغ بالاتر → نباید اشتباهاً «بیش از مانده» reject شود.
|
||||
@@ -12,6 +12,9 @@ interface SessionData {
|
||||
session_at: number | null; paid_at: number | null;
|
||||
services_total_rials: number; consumables_total_rials: number;
|
||||
discount_rials: number; final_price_rials: number; paid_total_rials: number;
|
||||
gross_total_rials?: number; base_insurance_rials?: number;
|
||||
supplementary_insurance_rials?: number; patient_share_rials?: number;
|
||||
remaining_rials?: number;
|
||||
payments: SessionPayment[]; consumables: SessionConsumable[];
|
||||
}
|
||||
interface Invoice {
|
||||
@@ -64,11 +67,22 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
||||
const session = inv?.session ?? null;
|
||||
|
||||
const paid = inv?.status === 'paid';
|
||||
// با session: مبالغ واقعی پرداخت (مبلغ نهایی منهای تخفیف و پرداختها)؛ بدون آن heuristic قبلی.
|
||||
// مانده را سرور میدهد (session.remaining_rials)؛ محاسبهی محلی با صفحهی پرداخت واگرا میشد.
|
||||
const remaining = session
|
||||
? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
|
||||
? session.remaining_rials ?? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
|
||||
: inv ? (paid ? 0 : inv.patient_rials) : 0;
|
||||
const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0;
|
||||
|
||||
// یک شکل واحد برای خلاصهی مالی؛ با session از خود مراجعه، بدون آن از فاکتور.
|
||||
const summary = {
|
||||
services: session ? session.services_total_rials : inv?.total_rials ?? 0,
|
||||
consumables: session?.consumables_total_rials ?? 0,
|
||||
discount: session?.discount_rials ?? 0,
|
||||
baseInsurance: session?.base_insurance_rials ?? inv?.base_insurance_rials ?? 0,
|
||||
suppInsurance: session?.supplementary_insurance_rials ?? inv?.supplementary_rials ?? 0,
|
||||
patientShare: session?.patient_share_rials ?? session?.final_price_rials ?? inv?.patient_rials ?? 0,
|
||||
gross: session?.gross_total_rials ?? inv?.total_rials ?? 0,
|
||||
};
|
||||
// وضعیت واقعی پرداخت (مستقل از وضعیت فریزشدهی فاکتور): تسویهشده اگر مانده صفر.
|
||||
const statusLabel = session ? (remaining <= 0 ? 'تسویه شده' : 'بدهکار') : (inv ? (STATUS_LABEL[inv.status] ?? inv.status) : '');
|
||||
|
||||
@@ -101,27 +115,19 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
||||
: [['-', '-', '-']]}
|
||||
/>
|
||||
)}
|
||||
{session ? (
|
||||
<SectionTable
|
||||
title="خلاصه مالی"
|
||||
cols={['جمع مبلغ سرویس', 'جمع مبلغ کالا', 'تخفیف', 'سهم بیمه پایه', 'سهم بیمه تکمیلی', 'مبلغ کل']}
|
||||
cols={['جمع مبلغ سرویس', 'جمع مبلغ کالا', 'سهم بیمه پایه', 'سهم بیمه تکمیلی', 'سهم بیمار', 'تخفیف', 'مبلغ نهایی']}
|
||||
rows={[[
|
||||
formatRial(session.services_total_rials), formatRial(session.consumables_total_rials),
|
||||
session.discount_rials > 0 ? formatRial(session.discount_rials) : '-',
|
||||
formatRial(inv.base_insurance_rials), formatRial(inv.supplementary_rials),
|
||||
formatRial(session.final_price_rials),
|
||||
formatRial(summary.services),
|
||||
summary.consumables > 0 ? formatRial(summary.consumables) : '-',
|
||||
formatRial(summary.baseInsurance),
|
||||
formatRial(summary.suppInsurance),
|
||||
formatRial(summary.patientShare),
|
||||
summary.discount > 0 ? formatRial(summary.discount) : '-',
|
||||
formatRial(Math.max(0, summary.patientShare - summary.discount)),
|
||||
]]}
|
||||
/>
|
||||
) : (
|
||||
<SectionTable
|
||||
title="خلاصه مالی"
|
||||
cols={['جمع مبلغ سرویس', 'سهم بیمه پایه', 'سهم بیمه تکمیلی', 'سهم بیمار', 'مبلغ کل']}
|
||||
rows={[[
|
||||
formatRial(inv.total_rials), formatRial(inv.base_insurance_rials),
|
||||
formatRial(inv.supplementary_rials), formatRial(inv.patient_rials), formatRial(inv.total_rials),
|
||||
]]}
|
||||
/>
|
||||
)}
|
||||
{session && (
|
||||
<SectionTable
|
||||
title="پرداختی ها"
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface SessionCardData {
|
||||
supplementary_discount_percent?: number;
|
||||
doctor_name?: string | null;
|
||||
final_price_rials?: number;
|
||||
// تفکیک بیمه — سرور محاسبه میکند (PatientSession::applyShares)
|
||||
gross_total_rials?: number;
|
||||
base_insurance_rials?: number;
|
||||
supplementary_insurance_rials?: number;
|
||||
patient_share_rials?: number;
|
||||
remaining_rials?: number;
|
||||
patient_debt_rials?: number;
|
||||
is_paid?: boolean;
|
||||
invoice_uuid?: string | null;
|
||||
|
||||
@@ -116,10 +116,17 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
|
||||
|
||||
const finalPrice = session.final_price_rials ?? 0;
|
||||
const discountRials = session.discount_rials ?? 0;
|
||||
const debt = session.patient_debt_rials ?? 0;
|
||||
const payments = session.payments ?? [];
|
||||
const appliedRuleLabel = session.applied_discount_rule_label ?? null;
|
||||
const payable = Math.max(0, finalPrice - discountRials);
|
||||
// تفکیک بیمه و مانده را سرور میدهد؛ محاسبهی محلی باعث واگرایی با فاکتور میشد.
|
||||
const grossTotal = session.gross_total_rials ?? finalPrice;
|
||||
const baseInsurance = session.base_insurance_rials ?? 0;
|
||||
const suppInsurance = session.supplementary_insurance_rials ?? 0;
|
||||
const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
|
||||
const payable = session.remaining_rials !== undefined
|
||||
? session.remaining_rials + (session.paid_total_rials ?? 0)
|
||||
: Math.max(0, finalPrice - discountRials);
|
||||
const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -194,8 +201,28 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
|
||||
|
||||
{/* خلاصهی مبلغ: قبل از تخفیف / تخفیف / نهایی / منبع */}
|
||||
<div className="dark:border-[#35343D]" style={{ border: '1px solid #e0e0e0', borderRadius: 8, padding: 12, marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{hasInsurance && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>مبلغ قبل از تخفیف:</span>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>هزینه کل خدمات:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>{formatRial(grossTotal)}</span>
|
||||
</div>
|
||||
{baseInsurance > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>سهم بیمه پایه:</span>
|
||||
<span style={{ color: '#2E7D32' }}>{formatRial(baseInsurance)}</span>
|
||||
</div>
|
||||
)}
|
||||
{suppInsurance > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>سهم بیمه تکمیلی:</span>
|
||||
<span style={{ color: '#2E7D32' }}>{formatRial(suppInsurance)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>{hasInsurance ? 'سهم بیمار:' : 'مبلغ قبل از تخفیف:'}</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>{formatRial(finalPrice)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
|
||||
@@ -539,7 +539,6 @@ export interface ServiceItem {
|
||||
staff_members?: { uuid: string; full_name: string }[];
|
||||
active: boolean;
|
||||
insurance_covered?: boolean;
|
||||
insurance_price_rials?: number | null;
|
||||
duration_minutes?: number | null;
|
||||
bookable?: boolean;
|
||||
}
|
||||
@@ -670,7 +669,14 @@ export interface PatientSession {
|
||||
base_insurance_discount_percent: string;
|
||||
supplementary_discount_percent: string;
|
||||
services_total_rials: number;
|
||||
/** تفکیک بیمه — سرور محاسبه میکند؛ کلاینت هرگز خودش نسازد */
|
||||
gross_total_rials?: number;
|
||||
base_insurance_rials?: number;
|
||||
supplementary_insurance_rials?: number;
|
||||
patient_share_rials?: number;
|
||||
final_price_rials: number;
|
||||
remaining_rials?: number;
|
||||
paid_total_rials?: number;
|
||||
payment_method: string;
|
||||
is_paid?: boolean;
|
||||
services?: SessionServiceLine[];
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"price_rials": 500000,
|
||||
"active": true,
|
||||
"insurance_covered": false,
|
||||
"insurance_price_rials": null,
|
||||
"duration_minutes": 50,
|
||||
"bookable": true,
|
||||
"created_at": 1718000000,
|
||||
@@ -137,7 +136,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"price_rials": 850000,
|
||||
"active": true,
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": null,
|
||||
"duration_minutes": 30,
|
||||
"bookable": true,
|
||||
"staff": { "uuid": "...", "full_name": "مریم امینی" },
|
||||
@@ -213,7 +211,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"price_rials": 500000,
|
||||
"staff_uuid": "...",
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 200000,
|
||||
"duration_minutes": 50,
|
||||
"bookable": true
|
||||
}
|
||||
@@ -227,7 +224,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
|
||||
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر `staff_uuids` نباشد استفاده میشود) |
|
||||
| insurance_covered | boolean | ❌ (پیشفرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمیفرستد؛ مقدارش بهصورت خودکار از ردیفهای پوشش بیمه همگام میشود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
|
||||
| insurance_price_rials | integer\|null | ❌ — **deprecated.** سهم تقریبی بیمار؛ از فرم سرویس حذف شد. محاسبهی دقیق سهم بیمار از `TenantServiceCoverage` انجام میشود |
|
||||
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
|
||||
| bookable | boolean | ❌ (پیشفرض false) — «نمایش در نوبتدهی». فقط سرویسهای `bookable=true` در حالت نوبتدهی سرویسی قابلانتخاباند |
|
||||
| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` |
|
||||
@@ -235,7 +231,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
|
||||
> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته میشود.
|
||||
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered`)
|
||||
|
||||
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` بهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
|
||||
|
||||
@@ -252,7 +248,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"staff_uuid": null,
|
||||
"active": false,
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 250000,
|
||||
"duration_minutes": 30
|
||||
}
|
||||
```
|
||||
|
||||
+23
-1
@@ -340,7 +340,12 @@ Returns paginated sessions for a patient record.
|
||||
"base_insurance_discount_percent": "10.00",
|
||||
"supplementary_discount_percent": "5.00",
|
||||
"services_total_rials": 50000,
|
||||
"gross_total_rials": 250000,
|
||||
"base_insurance_rials": 20000,
|
||||
"supplementary_insurance_rials": 0,
|
||||
"patient_share_rials": 230000,
|
||||
"final_price_rials": 230000,
|
||||
"remaining_rials": 0,
|
||||
"payment_method": "cash",
|
||||
"is_paid": true,
|
||||
"services": [
|
||||
@@ -378,6 +383,20 @@ Returns paginated sessions for a patient record.
|
||||
| `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` |
|
||||
| `patient_debt_rials` | int | مانده بدهی سهم بیمار؛ `0` اگر تسویه شده، وگرنه سهم بیمارِ فاکتور یا `final_price_rials` |
|
||||
|
||||
**تفکیک بیمه (پایا روی خود مراجعه):**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `gross_total_rials` | int | مبلغ کل پیش از بیمه: ویزیت + خدمات + کالاهای مصرفی |
|
||||
| `base_insurance_rials` | int | سهم بیمه پایه |
|
||||
| `supplementary_insurance_rials` | int | سهم بیمه تکمیلی (روی **باقیمانده پس از پایه** محاسبه میشود، نه روی کل) |
|
||||
| `patient_share_rials` | int | سهم بیمار پیش از تخفیف دستی؛ همیشه برابر `final_price_rials` |
|
||||
| `remaining_rials` | int | مانده: `max(0, patient_share − discount_rials − paid_total_rials)` |
|
||||
|
||||
ثابت همیشگی: `gross_total_rials = base_insurance_rials + supplementary_insurance_rials + patient_share_rials`
|
||||
|
||||
> این مقادیر را **سرور** با `BillingCalculator` محاسبه و روی `patient_sessions` ذخیره میکند (`PatientSession::applyShares`). کلاینت هرگز نباید سهمها یا مانده را خودش بسازد — صفحهی تکمیل پرداخت، مودال فاکتور و داشبورد Claims همگی باید همین فیلدها را بخوانند تا اختلاف محاسباتی ممکن نباشد. مراجعات پیش از این تغییر با `patient_share = final_price` و سهم بیمه صفر backfill شدهاند.
|
||||
|
||||
> **ثبت پرداخت («تکمیل پرداخت»):** از همان `PATCH /api/v1/session/{uuid}` با بدنهی `{ "payment_method": "cash" }` استفاده میشود؛ پس از آن `is_paid=true` و `patient_debt_rials=0` میشود. مشاهدهی فاکتور از `GET /api/v1/billing/invoices/{invoice_uuid}` (این endpoint اکنون برای منشیِ فعال هم در دسترس است).
|
||||
|
||||
**Errors:**
|
||||
@@ -483,10 +502,13 @@ Creates a new visit session for a patient record.
|
||||
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته میشود، وگرنه بیصدا نادیده گرفته میشود. روی قیمت اثری ندارد (فقط مرجع).
|
||||
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیشفرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه میشود. آیتم ناموجود یا متعلق به tenant دیگر بیصدا رد میشود (همرفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
|
||||
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیشفرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
|
||||
- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (`TenantInsurance.coveragePercent`) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
|
||||
- `final_price_rials` (سهم بیمار) به این صورت محاسبه میشود:
|
||||
- **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخابشده در فرم.
|
||||
- **ویزیت:** با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.)
|
||||
- **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
|
||||
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش میشوند.
|
||||
- **گیت پوشش:** اگر `ServiceItem.insurance_covered` غیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهای `TenantServiceCoverage` سینک میشود ([insurance.md](insurance.md)).
|
||||
- **سقف:** `annual_ceiling_rials` با وجود نامش بهصورت **سقف هر قلم** اعمال میشود؛ انباشت سالانهای در کد وجود ندارد.
|
||||
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیشنمایش پنل هم همین قاعده را سمت کلاینت آینه میکند.
|
||||
|
||||
**اتصال خودکار مطالبهی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت بهصورت خودکار صورتحساب ساخته و نهایی میشود و مطالبه(های) بیمه در وضعیت `pending` ایجاد میگردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحهی [مطالبات بیمه](billing.md) قابل پیگیری و ارسالاند. خطا در این مرحله ثبت session را خراب نمیکند (لاگ میشود). برای هر صورتحساب فقط یکبار مطالبه ساخته میشود.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Persist the insurance share breakdown on patient sessions so the payment page,
|
||||
* the invoice modal and the claims dashboard all read the same numbers instead of
|
||||
* each deriving their own.
|
||||
*/
|
||||
final class Version20260718184932 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add insurance share breakdown columns to patient_sessions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE patient_sessions '
|
||||
. 'ADD gross_total_rials INT DEFAULT 0 NOT NULL, '
|
||||
. 'ADD base_insurance_rials INT DEFAULT 0 NOT NULL, '
|
||||
. 'ADD supplementary_insurance_rials INT DEFAULT 0 NOT NULL, '
|
||||
. 'ADD patient_share_rials INT DEFAULT 0 NOT NULL');
|
||||
|
||||
// Existing sessions were computed without an insurance breakdown: the whole
|
||||
// amount was the patient's share. Backfilling this way keeps their totals unchanged.
|
||||
$this->addSql('UPDATE patient_sessions SET '
|
||||
. 'patient_share_rials = final_price_rials, '
|
||||
. 'gross_total_rials = final_price_rials, '
|
||||
. 'base_insurance_rials = 0, '
|
||||
. 'supplementary_insurance_rials = 0');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE patient_sessions '
|
||||
. 'DROP gross_total_rials, '
|
||||
. 'DROP base_insurance_rials, '
|
||||
. 'DROP supplementary_insurance_rials, '
|
||||
. 'DROP patient_share_rials');
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,10 @@ use App\Billing\Repository\InvoiceItemRepository;
|
||||
use App\Billing\Repository\InvoiceRepository;
|
||||
use App\Billing\Service\ClaimService;
|
||||
use App\Billing\Service\InvoiceService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Repository\InsuranceRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Security\PatientRecordScopeResolver;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -35,11 +34,8 @@ class BillingController extends BaseController
|
||||
private readonly ClaimRepository $claimRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly InsuranceRepository $insuranceRepo,
|
||||
private readonly \App\Secretary\Repository\DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly \App\Auth\Repository\UserActiveContextRepository $contextRepo,
|
||||
private readonly PatientRecordScopeResolver $scopeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -351,29 +347,13 @@ class BillingController extends BaseController
|
||||
return $record->getEntityType() === $entityType && $record->getEntityId() === $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط صورتحساب همان محیط پرونده است — صورتحساب و مطالبه از دل مراجعه بیرون میآیند.
|
||||
* ترتیب نقشها بهتنهایی کافی نبود: مالک کلینیکی که خودش پزشک هم هست به مطب شخصیاش
|
||||
* نگاشت میشد و صورتحسابهای کلینیک خودش را «یافت نشد» میگرفت.
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid !== null) {
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null && $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['unknown', null];
|
||||
return $this->scopeResolver->resolve($user)->toLegacyTuple();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +296,6 @@ class ClinicServiceController extends BaseController
|
||||
if (isset($data['insurance_covered'])) {
|
||||
$item->setInsuranceCovered((bool) $data['insurance_covered']);
|
||||
}
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
@@ -350,9 +347,6 @@ class ClinicServiceController extends BaseController
|
||||
if (isset($data['insurance_covered'])) {
|
||||
$item->setInsuranceCovered((bool) $data['insurance_covered']);
|
||||
}
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
|
||||
@@ -52,6 +52,10 @@ class ServiceItem
|
||||
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
|
||||
private bool $insuranceCovered = false;
|
||||
|
||||
/**
|
||||
* @deprecated منبع حقیقتِ پوشش، TenantServiceCoverage است و هیچ محاسبهای این مقدار
|
||||
* را نمیخواند. ستون برای دادهی تاریخی مانده ولی نه نوشته میشود و نه منتشر.
|
||||
*/
|
||||
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $insurancePriceRials = null;
|
||||
|
||||
@@ -148,7 +152,6 @@ class ServiceItem
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
|
||||
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
|
||||
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
|
||||
public function isBookable(): bool { return $this->bookable; }
|
||||
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
|
||||
@@ -187,7 +190,6 @@ class ServiceItem
|
||||
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
|
||||
@@ -219,7 +221,6 @@ class ServiceItem
|
||||
'price_rials' => $this->priceRials,
|
||||
'active' => $this->active,
|
||||
'insurance_covered' => $this->insuranceCovered,
|
||||
'insurance_price_rials' => $this->insurancePriceRials,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'bookable' => $this->bookable,
|
||||
'inventory_package_id' => $this->inventoryPackageId,
|
||||
|
||||
@@ -43,6 +43,7 @@ class InsuranceController extends BaseController
|
||||
private readonly ServiceItemRepository $serviceItemRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -78,17 +79,14 @@ class InsuranceController extends BaseController
|
||||
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
|
||||
}
|
||||
|
||||
/**
|
||||
* قرارداد بیمه به همان محیطی تعلق دارد که پروندهها در آن ثبت میشوند، پس همان
|
||||
* رزولوِر مبنا است: مالک کلینیکی که خودش پزشک هم هست باید قراردادهای کلینیکش را
|
||||
* ببیند، نه مطب شخصیاش.
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId()] : [EntityInsurancePricing::TYPE_DOCTOR, null];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? [EntityInsurancePricing::TYPE_CLINIC, $clinic->getId()] : [EntityInsurancePricing::TYPE_CLINIC, null];
|
||||
}
|
||||
return ['unknown', null];
|
||||
return $this->scopeResolver->resolve($user)->toLegacyTuple();
|
||||
}
|
||||
|
||||
// ── Public list ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,6 +58,24 @@ class PatientSession
|
||||
#[ORM\Column(name: 'services_total_rials', type: 'integer')]
|
||||
private int $servicesTotalRials = 0;
|
||||
|
||||
/**
|
||||
* تفکیک بیمهی این مراجعه، محاسبهشده توسط PatientService::calculateFinalPrice.
|
||||
* پایاست تا صفحهی پرداخت بدون صدور فاکتور هم سهمها را داشته باشد.
|
||||
* ثابت: gross = baseInsurance + supplementaryInsurance + patientShare
|
||||
*/
|
||||
#[ORM\Column(name: 'gross_total_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $grossTotalRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'base_insurance_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $baseInsuranceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'supplementary_insurance_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $supplementaryInsuranceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'patient_share_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $patientShareRials = 0;
|
||||
|
||||
/** سهم بیمار پیش از تخفیف دستی — همیشه برابر patientShareRials */
|
||||
#[ORM\Column(name: 'final_price_rials', type: 'integer')]
|
||||
private int $finalPriceRials = 0;
|
||||
|
||||
@@ -144,6 +162,10 @@ class PatientSession
|
||||
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
|
||||
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
|
||||
public function getServicesTotalRials(): int { return $this->servicesTotalRials; }
|
||||
public function getGrossTotalRials(): int { return $this->grossTotalRials; }
|
||||
public function getBaseInsuranceRials(): int { return $this->baseInsuranceRials; }
|
||||
public function getSupplementaryInsuranceRials(): int { return $this->supplementaryInsuranceRials; }
|
||||
public function getPatientShareRials(): int { return $this->patientShareRials; }
|
||||
public function getFinalPriceRials(): int { return $this->finalPriceRials; }
|
||||
public function getPaymentMethod(): string { return $this->paymentMethod; }
|
||||
public function getDiscountType(): ?string { return $this->discountType; }
|
||||
@@ -169,10 +191,16 @@ class PatientSession
|
||||
));
|
||||
}
|
||||
|
||||
/** مبلغ قابل پرداخت بیمار: سهم بیمار پس از کسر تخفیف دستی */
|
||||
public function getPayableRials(): int
|
||||
{
|
||||
return max(0, $this->finalPriceRials - $this->discountRials);
|
||||
}
|
||||
|
||||
/** ماندهی بدهی پس از کسر تخفیف و پرداختها؛ هرگز منفی نمیشود */
|
||||
public function getRemainingRials(): int
|
||||
{
|
||||
return max(0, $this->finalPriceRials - $this->discountRials - $this->getPaidTotalRials());
|
||||
return max(0, $this->getPayableRials() - $this->getPaidTotalRials());
|
||||
}
|
||||
|
||||
public function getSessionAt(): ?int { return $this->sessionAt; }
|
||||
@@ -206,7 +234,22 @@ class PatientSession
|
||||
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/**
|
||||
* تفکیک بیمه را یکجا مینشاند تا سهمها و مبلغ نهایی نتوانند ناسازگار شوند.
|
||||
* مبلغ نهایی همیشه سهم بیمار است؛ تخفیف دستی جدا و بعد از این اعمال میشود.
|
||||
*/
|
||||
public function applyShares(int $gross, int $baseInsurance, int $supplementaryInsurance, int $patientShare): self
|
||||
{
|
||||
$this->grossTotalRials = $gross;
|
||||
$this->baseInsuranceRials = $baseInsurance;
|
||||
$this->supplementaryInsuranceRials = $supplementaryInsurance;
|
||||
$this->patientShareRials = $patientShare;
|
||||
$this->finalPriceRials = $patientShare;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDiscount(?string $type, int $value, int $rials, ?int $ruleId = null, ?string $ruleLabel = null): self
|
||||
{
|
||||
@@ -242,7 +285,12 @@ class PatientSession
|
||||
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
|
||||
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
|
||||
'services_total_rials' => $this->servicesTotalRials,
|
||||
'gross_total_rials' => $this->grossTotalRials,
|
||||
'base_insurance_rials' => $this->baseInsuranceRials,
|
||||
'supplementary_insurance_rials' => $this->supplementaryInsuranceRials,
|
||||
'patient_share_rials' => $this->patientShareRials,
|
||||
'final_price_rials' => $this->finalPriceRials,
|
||||
'remaining_rials' => $this->getRemainingRials(),
|
||||
'payment_method' => $this->paymentMethod,
|
||||
'is_paid' => $this->getRemainingRials() === 0,
|
||||
'discount_type' => $this->discountType,
|
||||
|
||||
@@ -86,44 +86,69 @@ class PatientService
|
||||
}
|
||||
|
||||
/**
|
||||
* محاسبهی سهم بیمار.
|
||||
* ویزیت با درصد تخفیف انتخابشده در فرم؛ هر خدمت با قاعدهی پوشش همان بیمهگر برای همان خدمت
|
||||
* (TenantServiceCoverage از طریق BillingCalculator). خدمتی که آن بیمه را پوشش نمیدهد، کامل بر عهدهی بیمار است.
|
||||
* تفکیک سهم بیمهها و سهم بیمار برای یک مراجعه.
|
||||
*
|
||||
* ویزیت با قاعدهی قرارداد بیمه (coverageRule) و هر خدمت با قاعدهی پوشش همان خدمت
|
||||
* (coverageRuleForService) محاسبه میشود — هر دو از طریق BillingCalculator، همان مسیری
|
||||
* که InvoiceService برای صدور فاکتور استفاده میکند. تنها منبع محاسبه همین است تا مبلغ
|
||||
* صفحهی پرداخت و فاکتور نتوانند از هم واگرا شوند.
|
||||
*
|
||||
* @param array<array{item_id: int, price_rials: int}> $serviceItems قیمت کل هر ردیف (با احتساب تعداد)
|
||||
* @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int}
|
||||
*/
|
||||
public function calculateFinalPrice(
|
||||
int $visitPrice,
|
||||
float $baseDiscount,
|
||||
float $suppDiscount,
|
||||
array $serviceItems,
|
||||
string $entityType = 'doctor',
|
||||
int $entityId = 0,
|
||||
?int $baseInsuranceId = null,
|
||||
?int $suppInsuranceId = null,
|
||||
): array {
|
||||
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
|
||||
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
|
||||
$visitShare = (int) round($afterSupp);
|
||||
$baseShare = 0;
|
||||
$suppShare = 0;
|
||||
$patientShare = 0;
|
||||
|
||||
if ($visitPrice > 0) {
|
||||
$visit = $this->billingCalculator->calculateItem(
|
||||
new Money($visitPrice),
|
||||
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId),
|
||||
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId),
|
||||
);
|
||||
$baseShare += $visit->baseInsuranceRials;
|
||||
$suppShare += $visit->supplementaryRials;
|
||||
$patientShare += $visit->patientRials;
|
||||
}
|
||||
|
||||
$servicesTotal = 0;
|
||||
$servicesPatient = 0;
|
||||
foreach ($serviceItems as $svc) {
|
||||
$servicesTotal += $svc['price_rials'];
|
||||
|
||||
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']);
|
||||
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']);
|
||||
$breakdown = $this->billingCalculator->calculateItem(new Money($svc['price_rials']), $baseRule, $suppRule);
|
||||
|
||||
$servicesPatient += $breakdown->patientRials;
|
||||
$line = $this->billingCalculator->calculateItem(
|
||||
new Money($svc['price_rials']),
|
||||
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']),
|
||||
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']),
|
||||
);
|
||||
$baseShare += $line->baseInsuranceRials;
|
||||
$suppShare += $line->supplementaryRials;
|
||||
$patientShare += $line->patientRials;
|
||||
}
|
||||
|
||||
return [
|
||||
'services_total_rials' => $servicesTotal,
|
||||
'final_price_rials' => $visitShare + $servicesPatient,
|
||||
'gross_total_rials' => $visitPrice + $servicesTotal,
|
||||
'base_insurance_rials' => $baseShare,
|
||||
'supplementary_insurance_rials' => $suppShare,
|
||||
'patient_share_rials' => $patientShare,
|
||||
'final_price_rials' => $patientShare,
|
||||
];
|
||||
}
|
||||
|
||||
/** درصد پوشش قرارداد فعال — snapshot روی مراجعه، نه ورودی محاسبه. */
|
||||
private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float
|
||||
{
|
||||
return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* پرونده و مراجعهٔ خودکار برای یک نوبت قطعیشده.
|
||||
*
|
||||
@@ -190,7 +215,9 @@ class PatientService
|
||||
}
|
||||
|
||||
$session->setServicesTotalRials($servicesTotal);
|
||||
$session->setFinalPriceRials($servicesTotal + $visitPrice);
|
||||
// پذیرش خودکار بیمهای ندارد: تمام مبلغ سهم بیمار است.
|
||||
$gross = $servicesTotal + $visitPrice;
|
||||
$session->applyShares($gross, 0, 0, $gross);
|
||||
|
||||
$this->sessionRepo->save($session);
|
||||
|
||||
@@ -222,8 +249,8 @@ class PatientService
|
||||
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
|
||||
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
|
||||
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
|
||||
$session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
|
||||
$session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
|
||||
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
|
||||
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
|
||||
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
|
||||
$session->setNotes($data['notes'] ?? null);
|
||||
|
||||
@@ -252,8 +279,6 @@ class PatientService
|
||||
|
||||
$priceCalc = $this->calculateFinalPrice(
|
||||
$session->getVisitPriceRials(),
|
||||
$session->getBaseInsuranceDiscountPercent(),
|
||||
$session->getSupplementaryDiscountPercent(),
|
||||
$serviceItemsData,
|
||||
$entityType,
|
||||
$entityId,
|
||||
@@ -277,7 +302,13 @@ class PatientService
|
||||
$consumablesTotal += $item->getPrice() * $qty;
|
||||
}
|
||||
|
||||
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $consumablesTotal);
|
||||
// کالاهای مصرفی پوشش بیمه ندارند: مستقیم به سهم بیمار و به جمع کل اضافه میشوند.
|
||||
$session->applyShares(
|
||||
$priceCalc['gross_total_rials'] + $consumablesTotal,
|
||||
$priceCalc['base_insurance_rials'],
|
||||
$priceCalc['supplementary_insurance_rials'],
|
||||
$priceCalc['patient_share_rials'] + $consumablesTotal,
|
||||
);
|
||||
|
||||
$this->sessionRepo->save($session);
|
||||
|
||||
@@ -365,18 +396,25 @@ class PatientService
|
||||
fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()],
|
||||
$session->getServices()->toArray(),
|
||||
);
|
||||
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
|
||||
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
|
||||
|
||||
$priceCalc = $this->calculateFinalPrice(
|
||||
$session->getVisitPriceRials(),
|
||||
$session->getBaseInsuranceDiscountPercent(),
|
||||
$session->getSupplementaryDiscountPercent(),
|
||||
$serviceItemsData,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$session->getInsuranceBaseId(),
|
||||
$session->getInsuranceSupplementaryId(),
|
||||
);
|
||||
$consumablesTotal = $session->getConsumablesTotalRials();
|
||||
$session->setServicesTotalRials($priceCalc['services_total_rials']);
|
||||
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $session->getConsumablesTotalRials());
|
||||
$session->applyShares(
|
||||
$priceCalc['gross_total_rials'] + $consumablesTotal,
|
||||
$priceCalc['base_insurance_rials'],
|
||||
$priceCalc['supplementary_insurance_rials'],
|
||||
$priceCalc['patient_share_rials'] + $consumablesTotal,
|
||||
);
|
||||
$this->sessionRepo->save($session);
|
||||
|
||||
// پس از تغییر مبلغ، وضعیت تسویه بازمحاسبه شود (افزودن سرویس/پکیج → بدهکار).
|
||||
@@ -417,8 +455,7 @@ class PatientService
|
||||
}
|
||||
// مجموع پرداختها (با مقدار جدید) نباید از مبلغِ پس از تخفیف بیشتر شود.
|
||||
$othersTotal = $session->getPaidTotalRials() - $oldAmount;
|
||||
$payable = $session->getFinalPriceRials() - $session->getDiscountRials();
|
||||
if ($othersTotal + $newAmount > $payable) {
|
||||
if ($othersTotal + $newAmount > $session->getPayableRials()) {
|
||||
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\Insurance;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\Insurance\Enum\InsuranceType;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST /api/v1/patient/{uuid}/session — the insurance share breakdown persisted on
|
||||
* the session. The payment page, the invoice modal and the claims dashboard all read
|
||||
* these fields, so they must come out of BillingCalculator and never be re-derived.
|
||||
*/
|
||||
class SessionInsuranceShareTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor, 2: PatientRecord} */
|
||||
private function tenant(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $record];
|
||||
}
|
||||
|
||||
private function serviceItem(Doctor $doctor, int $priceRials, bool $insuranceCovered): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'جراحی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$item = new ServiceItem($section, 'جراحی بینی', $priceRials);
|
||||
$item->setInsuranceCovered($insuranceCovered);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** Active contract covering $coveragePercent of every covered service. */
|
||||
private function contract(Doctor $doctor, float $coveragePercent, ?ServiceItem $item = null): Insurance
|
||||
{
|
||||
$insurance = new Insurance('تامین اجتماعی', InsuranceType::Basic);
|
||||
$this->em->persist($insurance);
|
||||
$this->em->flush();
|
||||
|
||||
$contract = new TenantInsurance('doctor', $doctor->getId(), $insurance->getId());
|
||||
$contract->setCoveragePercent($coveragePercent)->setActive(true);
|
||||
$this->em->persist($contract);
|
||||
$this->em->flush();
|
||||
|
||||
if ($item !== null) {
|
||||
$coverage = new TenantServiceCoverage($contract->getId(), $item->getId());
|
||||
$coverage->setCovered(true)->setCoveragePercent($coveragePercent);
|
||||
$this->em->persist($coverage);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $insurance;
|
||||
}
|
||||
|
||||
public function testSessionStoresInsuranceAndPatientSharesForACoveredService(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 40_000_000, true);
|
||||
$insurance = $this->contract($doctor, 70.0, $item);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 3_000_000,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$session = $res['data'];
|
||||
// 70% of both the visit and the service is carried by the insurer.
|
||||
self::assertSame(43_000_000, $session['gross_total_rials']);
|
||||
self::assertSame(30_100_000, $session['base_insurance_rials']);
|
||||
self::assertSame(0, $session['supplementary_insurance_rials']);
|
||||
self::assertSame(12_900_000, $session['patient_share_rials']);
|
||||
// The payable amount is the patient share, not the gross total.
|
||||
self::assertSame(12_900_000, $session['final_price_rials']);
|
||||
self::assertSame(12_900_000, $session['remaining_rials']);
|
||||
}
|
||||
|
||||
public function testTheBreakdownAlwaysSumsBackToTheGrossTotal(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 1_234_567, true);
|
||||
$insurance = $this->contract($doctor, 33.33, $item);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 987_654,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 3]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(
|
||||
$s['gross_total_rials'],
|
||||
$s['base_insurance_rials'] + $s['supplementary_insurance_rials'] + $s['patient_share_rials'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testAServiceWithoutCoverageLeavesTheWholeAmountToThePatient(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 40_000_000, false);
|
||||
$insurance = $this->contract($doctor, 70.0);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(0, $s['base_insurance_rials']);
|
||||
self::assertSame(40_000_000, $s['patient_share_rials']);
|
||||
self::assertSame(40_000_000, $s['final_price_rials']);
|
||||
}
|
||||
|
||||
public function testSessionWithoutInsuranceKeepsTheFullAmountAsThePatientShare(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 2_400_000, true);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 500_000,
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(2_900_000, $s['gross_total_rials']);
|
||||
self::assertSame(0, $s['base_insurance_rials']);
|
||||
self::assertSame(2_900_000, $s['patient_share_rials']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user