feat: Enhance insurance billing system to support supplementary insurance

- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials.
- Modified Appointment entity to carry supplementary insurance ID alongside base insurance.
- Implemented SessionBillingService to ensure finalized invoices for insured patient sessions.
- Created InvoiceFinalized event to trigger claims creation upon invoice finalization.
- Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims.
- Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
This commit is contained in:
hamed
2026-07-29 19:57:02 +03:30
parent 9b05c6d1ff
commit e6267080b2
25 changed files with 876 additions and 126 deletions
@@ -130,9 +130,16 @@ const referenceAppointment = {
visit_price_rials: 5_952_000, service_items: [],
};
const SUPP_CONTRACT = {
uuid: 'c2', insurance_id: 9, insurance_name: 'بیمه آسیا', insurance_kind: 'supplementary',
is_active: true, coverage_percent: 90, franchise_percent: 10, annual_ceiling_rials: null,
category_coverages: { outpatient: 90, inpatient: 60 },
};
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') {
@@ -148,7 +155,7 @@ function mockInsurance(
});
}
if (url === '/api/v1/billing/tenant-insurances') {
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
return Promise.resolve({ success: true, data: { data: contracts } });
}
return Promise.resolve({ success: true, data: [] });
});
@@ -180,7 +187,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
renderReference();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
@@ -190,7 +197,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
]);
renderReference();
expect(await screen.findByText('بیمه')).toBeInTheDocument();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
@@ -224,6 +231,52 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
}));
});
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH);
renderReference();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
});
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه', 'بیمه ایران');
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// پایه ۷۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۱۶۶٬۴۰۰؛ باقیمانده ۱٬۷۸۵٬۶۰۰؛
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
insurance_service_category: 'outpatient',
insurance_base_id: 3,
insurance_supplementary_id: 9,
payments: [{ method: 'cash', amount_rials: 357_120 }],
}));
});
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000);
renderWithProviders(
@@ -36,6 +36,7 @@ interface AppointmentLike {
patient_name?: string | null;
insurance_service_category?: string | null;
insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
}
interface Props {
@@ -148,12 +149,14 @@ export default function ConfirmAppointmentModal({
const total = visitPrice + servicesTotal;
const [serviceCategory, setServiceCategory] = useState<string>('');
const [insuranceId, setInsuranceId] = useState<string>('');
const [supplementaryId, setSupplementaryId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant.
useEffect(() => {
if (!open) return;
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
setSupplementaryId(appt?.insurance_supplementary_id ? String(appt.insurance_supplementary_id) : '');
}, [open, appt?.uuid, insurance.defaultCategory]);
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
@@ -166,9 +169,10 @@ export default function ConfirmAppointmentModal({
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
insured: s.insurance_covered !== false,
})),
], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
], insuranceId, supplementaryId), [visitPrice, services, effectiveCategory, insuranceId, supplementaryId, insurance.breakdown]);
const payable = insuranceId ? shares.patient : total;
const hasInsurance = !!insuranceId || !!supplementaryId;
const payable = hasInsurance ? shares.patient : total;
// ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است.
@@ -196,6 +200,7 @@ export default function ConfirmAppointmentModal({
version: appt?.version,
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}),
payments: rows
.filter(r => tomanToRial(r.amountToman) > 0)
.map(r => ({
@@ -305,7 +310,7 @@ export default function ConfirmAppointmentModal({
</div>
)}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label>
<label>بیمه پایه</label>
<SearchableSelect
value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
@@ -316,6 +321,19 @@ export default function ConfirmAppointmentModal({
height={40}
/>
</div>
{insurance.hasSupplementary && (
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه تکمیلی</label>
<SearchableSelect
value={supplementaryId}
onChange={(v) => setSupplementaryId(v == null ? '' : String(v))}
options={insurance.supplementaryOptions}
placeholder="بدون بیمه تکمیلی"
isClearable
height={40}
/>
</div>
)}
</div>
{/* هزینه‌ها */}
@@ -339,10 +357,26 @@ export default function ConfirmAppointmentModal({
<span>جمع کل</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
</div>
{/* سهم‌ها زنجیره‌ای‌اند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده
می‌شوند تا معلوم باشد هرکدام چقدر برداشته‌اند. */}
{insuranceId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong>
<span>
سهم بیمه پایه
{insurance.insuranceNameOf(insuranceId) ? `${insurance.insuranceNameOf(insuranceId)}` : ''}
{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}
</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.base)}</strong>
</div>
)}
{supplementaryId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>
سهم بیمه تکمیلی
{insurance.insuranceNameOf(supplementaryId) ? `${insurance.insuranceNameOf(supplementaryId)}` : ''}
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}> (روی باقیمانده)</span>
</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.supplementary)}</strong>
</div>
)}
<div
@@ -351,7 +385,7 @@ export default function ConfirmAppointmentModal({
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<span>{hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div>
</div>
+25 -9
View File
@@ -47,10 +47,18 @@ export function useAppointmentInsurance(enabled: boolean) {
[contractsQuery.data],
);
const activeContracts = useMemo(() => contracts.filter((c) => c.is_active !== false), [contracts]);
const basicContracts = useMemo(
() => contracts.filter((c) => c.is_active !== false && c.insurance_kind !== 'supplementary'),
[contracts],
() => activeContracts.filter((c) => c.insurance_kind !== 'supplementary'),
[activeContracts],
);
const supplementaryContracts = useMemo(
() => activeContracts.filter((c) => c.insurance_kind === 'supplementary'),
[activeContracts],
);
const optionsOf = (list: TenantContract[]) =>
list.map((c) => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
return {
/**
@@ -70,18 +78,26 @@ export function useAppointmentInsurance(enabled: boolean) {
categoryLabelOf: (key: string | null | undefined) =>
(pricing.service_categories ?? []).find((c) => c.key === key)?.label ?? null,
insuranceOptions: basicContracts.map((c) => ({
value: String(c.insurance_id),
label: c.insurance_name ?? `#${c.insurance_id}`,
})),
insuranceOptions: optionsOf(basicContracts),
/** قراردادهای تکمیلیِ فعال — روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شوند. */
supplementaryOptions: optionsOf(supplementaryContracts),
hasSupplementary: supplementaryContracts.length > 0,
contractOf: (insuranceId: string | number | null | undefined): TenantContract | null =>
basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null,
insuranceNameOf: (insuranceId: string | number | null | undefined): string | null =>
contracts.find((c) => String(c.insurance_id) === String(insuranceId))?.insurance_name ?? null,
/** تفکیک سهم بیمه/بیمار برای ردیف‌های داده‌شده تحت قرارداد یک بیمه. */
breakdown: (lines: BillableLine[], insuranceId: string | number | null | undefined): ShareBreakdown =>
breakdownOf(lines, basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null),
/** تفکیک زنجیره‌ای سهم‌ها: پایه روی کل، تکمیلی روی باقیمانده. */
breakdown: (
lines: BillableLine[],
insuranceId: string | number | null | undefined,
supplementaryId: string | number | null | undefined = null,
): ShareBreakdown =>
breakdownOf(
lines,
basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null,
supplementaryContracts.find((c) => String(c.insurance_id) === String(supplementaryId)) ?? null,
),
isLoading: pricingQuery.isLoading || contractsQuery.isLoading,
};
+39 -19
View File
@@ -27,19 +27,25 @@ export interface TenantContract {
/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
/** سهم بیمهٔ پایه از کل مبلغ یک ردیف (با سقف تعهد). */
export function baseShareOf(total: number, base: CoverageRule | null): number {
if (!base || !base.covered) return 0;
const share = Math.round(total * (base.percent / 100));
return base.ceiling !== null ? Math.min(share, base.ceiling) : share;
}
/**
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی.
* فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر می‌شود،
* نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی. محاسبه زنجیره‌ای است؛
* تکمیلی روی باقیماندهٔ بعد از پایه کار می‌کند، نه روی کل مبلغ. فرانشیزِ تکمیلی
* درصدی از همان باقیمانده است و از سهم تکمیلی کسر می‌شود، نه اینکه روی سهم بیمار
* سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
*/
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
let baseShare = 0;
let remaining = total;
if (base && base.covered) {
baseShare = Math.round(total * (base.percent / 100));
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
remaining = total - baseShare;
}
const baseShare = baseShareOf(total, base);
const remaining = total - baseShare;
let suppShare = 0;
if (supp && supp.covered) {
suppShare = Math.max(
@@ -80,21 +86,35 @@ export interface BillableLine {
export interface ShareBreakdown {
total: number;
/** مجموع سهم هر دو بیمه — برای نمایشِ یک‌خطی. */
insurance: number;
base: number;
supplementary: number;
patient: number;
}
/** تفکیک سهم بیمه/بیمار برای مجموعه‌ای از ردیف‌ها تحت یک قرارداد پایه. */
export function breakdownOf(lines: BillableLine[], base: TenantContract | null): ShareBreakdown {
/**
* تفکیک سهم برای مجموعه‌ای از ردیف‌ها تحت قرارداد پایه و (اختیاری) تکمیلی.
* محاسبه زنجیره‌ای است: پایه روی کل، تکمیلی روی باقیماندهٔ بعد از پایه.
*/
export function breakdownOf(
lines: BillableLine[],
base: TenantContract | null,
supplementary: TenantContract | null = null,
): ShareBreakdown {
return lines.reduce<ShareBreakdown>((acc, line) => {
const patient = line.insured
? patientShareOf(line.total, ruleOf(base, line.category), null)
: line.total;
const baseRule = line.insured ? ruleOf(base, line.category) : null;
const suppRule = line.insured ? ruleOf(supplementary, line.category) : null;
const patient = line.insured ? patientShareOf(line.total, baseRule, suppRule) : line.total;
const baseShare = baseShareOf(line.total, baseRule);
return {
total: acc.total + line.total,
insurance: acc.insurance + (line.total - patient),
patient: acc.patient + patient,
total: acc.total + line.total,
base: acc.base + baseShare,
supplementary: acc.supplementary + (line.total - patient - baseShare),
insurance: acc.insurance + (line.total - patient),
patient: acc.patient + patient,
};
}, { total: 0, insurance: 0, patient: 0 });
}, { total: 0, base: 0, supplementary: 0, insurance: 0, patient: 0 });
}
+25 -10
View File
@@ -367,6 +367,7 @@ Get appointment detail.
| `insurance_service_category` | string \| null | نوع خدمتِ بیمه‌ایِ این نوبت — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). `null` = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (`default_service_category` در [insurance.md](insurance.md)) |
| `insurance_service_category_label` | string \| null | برچسب فارسی همان نوع |
| `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد |
| `insurance_supplementary_id` | int \| null | بیمهٔ **تکمیلیِ** انتخاب‌شده؛ قرارداد فعال لازم دارد و روی **باقیماندهٔ بعد از بیمهٔ پایه** محاسبه می‌شود ([فرمول زنجیره‌ای](billing.md)) |
نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند.
@@ -557,6 +558,7 @@ transaction**. If any step fails nothing is committed.
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. |
| `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. |
| `insurance_supplementary_id` | integer | ❌ | بیمهٔ تکمیلی، همان قواعد `PATCH`. روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شود. |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). |
| `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` |
| `payments[].amount_rials` | integer | ✅ | > 0 |
@@ -584,8 +586,10 @@ collected later through `POST /api/v1/session/{uuid}/payments`.
A `PatientSession` is opened with the visit price and one line per attached service.
4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از
نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve
([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). نوبتِ بدون بیمه
مثل قبل کاملاً سهم بیمار می‌ماند.
([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). محاسبه زنجیره‌ای
است: پایه روی کل، تکمیلی روی باقیمانده. نوبتِ بدون بیمه مثل قبل کاملاً سهم بیمار می‌ماند.
مراجعهٔ بیمه‌دار همین‌جا صورتحساب نهایی و مطالبهٔ بیمه هم می‌گیرد
(`SessionBillingService` → رویداد `InvoiceFinalized`؛ [billing.md](billing.md)).
5. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
> پیش از این، پرونده‌ای که با قطعی‌کردن ساخته می‌شد همیشه کل مبلغ را سهم بیمار می‌گذاشت
@@ -593,7 +597,7 @@ collected later through `POST /api/v1/session/{uuid}/payments`.
پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`،
`supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`،
`insurance_base_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است.
`insurance_base_id`، `insurance_supplementary_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است.
### Response `200`
```json
@@ -603,18 +607,28 @@ collected later through `POST /api/v1/session/{uuid}/payments`.
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": {
"uuid": "…",
"visit_price_rials": 5000000,
"services_total_rials": 1500000,
"final_price_rials": 6500000,
"visit_price_rials": 5950000,
"services_total_rials": 0,
"final_price_rials": 833000,
"discount_rials": 0,
"paid_total_rials": 5000000,
"remaining_rials": 1500000,
"is_paid": false
"paid_total_rials": 0,
"remaining_rials": 833000,
"is_paid": false,
"insurance_service_category": "outpatient",
"insurance_base_id": 176,
"insurance_supplementary_id": 182,
"gross_total_rials": 5950000,
"base_insurance_rials": 1785000,
"supplementary_insurance_rials": 3332000,
"patient_share_rials": 833000
}
}
}
```
> نمونهٔ بالا خروجی واقعیِ همان مسیر است: ویزیت ۵٬۹۵۰٬۰۰۰ · پایه ۳۰٪ سرپایی → ۱٬۷۸۵٬۰۰۰ ·
> تکمیلیِ ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۴٬۱۶۵٬۰۰۰ → ۳٬۳۳۲٬۰۰۰ · سهم بیمار ۸۳۳٬۰۰۰.
`session` is `null` when the tenant does not have the `patient_records` subscription
feature — the appointment is still confirmed, it simply has no case file. **Sending
`payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms
@@ -876,7 +890,8 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
- `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`.
- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال یا بیمهٔ تکمیلی`422 ERR_VALIDATION_001` با فیلد `insurance_base_id`.
- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال → `422 ERR_VALIDATION_001` («این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد»)؛ فرستادن بیمهٔ تکمیلی در این فیلد → `422` («اینجا فقط بیمهٔ پایه قابل انتخاب است»)، هر دو با فیلد `insurance_base_id`.
- `insurance_supplementary_id` — همان قواعد، برعکس: فقط قرارداد فعالِ **تکمیلی** پذیرفته می‌شود؛ فرستادن بیمهٔ پایه → `422` («اینجا فقط بیمهٔ تکمیلی قابل انتخاب است») با فیلد `insurance_supplementary_id`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version``409` on concurrent edit.
+51 -21
View File
@@ -12,20 +12,43 @@
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی).
- `invoices.service_category` همان نوع را snapshot می‌کند و در `toArray()` به‌صورت `service_category` / `service_category_label` برمی‌گردد.
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار.
**بیمهٔ پایه صرفاً درصدی است:**
**محاسبه زنجیره‌ای است، نه هم‌زمان:** اول بیمهٔ پایه روی کل مبلغ، بعد بیمهٔ تکمیلی روی
**باقیماندهٔ سهم بیمار**، نه روی کل.
```
سهم بیمهٔ پایه = round(کل × درصد پوشش پایه ÷ 100)
سهم بیمار = کل سهم بیمهٔ پایه
سهم پایه = min(round(کل × درصد پایه ÷ 100), سقف پایه)
باقیمانده = کل سهم پایه
سهم تکمیلی = min(max(0, round(باقیمانده × درصد تکمیلی ÷ 100) − round(باقیمانده × فرانشیز ÷ 100)), سقف تکمیلی)
سهم بیمار = کل − سهم پایه − سهم تکمیلی
```
`franchise_rials` قرارداد پایه در محاسبه **بی‌اثر** است (ستون برای سازگاری و قراردادهای تکمیلی می‌ماند).
- **فرانشیز درصد است، نه مبلغ**، و از تعهد بیمهٔ تکمیلی **کسر** می‌شود (نه اینکه روی سهم بیمار
اضافه شود). با مدل قبلیِ «افزودن به سهم بیمار»، جمع سهم‌ها از کل بیشتر می‌شد و مطالبهٔ
ارسالی به بیمه بیش از سهم واقعی‌اش بود.
- `franchise_percent` قرارداد **پایه** در محاسبه بی‌اثر است.
- invariant همیشگی: `کل = سهم پایه + سهم تکمیلی + سهم بیمار`.
نمونه‌ها:
- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
- کل ۱۰٬۰۰۰٬۰۰۰ · پایه ۳۰٪ → ۳٬۰۰۰٬۰۰۰ · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۷٬۰۰۰٬۰۰۰ → ۵٬۶۰۰٬۰۰۰ · بیمار ۱٬۴۰۰٬۰۰۰.
- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
- بدون پایه · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ → بیمه ۸۰۰٬۰۰۰ · بیمار ۲۰۰٬۰۰۰.
### مطالبهٔ خودکار
مطالبهٔ بیمه اثر جانبیِ **نهایی‌شدن صورتحساب** است، نه کارِ یک endpoint خاص: `InvoiceService::finalize()`
رویداد `InvoiceFinalized` می‌فرستد و `CreateClaimsOnInvoiceFinalized` مطالبات جاافتاده را می‌سازد
(idempotent — مطالبهٔ موجود دست‌نخورده می‌ماند، و بیمه‌ای که سهمی نبرده مطالبه نمی‌گیرد).
هر مراجعهٔ بیمه‌دار هم — چه از «ثبت مراجعه»، چه از ویرایش مراجعه، چه از **قطعی‌کردن نوبت**
با `SessionBillingService::ensureFinalizedInvoice()` صورتحساب نهایی می‌گیرد. پیش از این فقط
مسیر «ثبت مراجعه» مطالبه می‌ساخت و بقیهٔ مسیرها بی‌صدا بدون مطالبه می‌ماندند.
جبران داده‌های قدیمی:
```bash
ddev exec php bin/console app:billing:backfill-claims --dry-run # گزارش
ddev exec php bin/console app:billing:backfill-claims # ساخت مطالبات جاافتاده
```
---
@@ -216,9 +239,10 @@
| `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 | — | نام، موبایل یا کد ملی بیمار |
| `search` | string | — | نام، موبایل، کد ملی بیمار **یا نام بیمه** |
| `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` |
| `insurance_id` | int | — | شناسه بیمه |
| `kind` | string | — | نوع بیمه: `base` \| `supplementary` (واژگان مطالبه `base` است، نه `basic`). مقدار نامعتبر → `422 ERR_VALIDATION_001` با `field: kind` |
| `doctor_id` | int | — | پزشکِ نوبتِ مراجعه |
| `payment_status` | string | — | `paid` (وصول‌شده) \| `unpaid` |
| `from` / `to` | int | — | بازه‌ی `claims.created_at` (unix ثانیه) |
@@ -230,25 +254,31 @@
"success": true,
"data": [
{
"patient_uuid": "45064492-...",
"record_uuid": "ad0a3d0e-...",
"full_name": "تست جراحی بینی",
"mobile": "09370671756",
"patient_uuid": "649e24ae-08e2-489d-b502-af07c59dfa2d",
"record_uuid": "8e36035c-d234-42aa-858b-2427918e9c4a",
"full_name": "تست بیمه ۱",
"mobile": "09243243534",
"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
"claims_count": 3,
"total_services_rials": 91900000,
"total_insurance_rials": 9877000,
"total_patient_rials": 82023000,
"total_approved_rials": 0,
"total_paid_rials": 0,
"overall_status": "pending",
"insurances": [
{ "insurance_id": 176, "insurance_name": "تامین اجتماعی", "kind": "base" },
{ "insurance_id": 182, "insurance_name": "بیمه ایران", "kind": "supplementary" }
],
"last_activity_at": 1785341593
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1, "limit": 1 }
}
```
- `insurances`: بیمه‌هایی که این بیمار زیرشان مطالبه دارد — یک بیمار می‌تواند هم‌زمان پایه و تکمیلی داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار.
- `overall_status`: اگر همه‌ی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`.
- ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`.
- **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد.
+18 -7
View File
@@ -33,7 +33,7 @@ hardcode نمی‌شود. ویزیت همیشه `outpatient` است.
| ۴ | `coverage_percent` قرارداد (سازگاری با ردیف‌های قدیمی) | `tenant_insurances` |
**fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیش‌فرض ادمین
خودبه‌خود به‌روز می‌شود. `franchise_rials` فقط در قراردادهای `supplementary` اثر دارد.
خودبه‌خود به‌روز می‌شود. `franchise_percent` فقط در قراردادهای `supplementary` اثر دارد.
---
@@ -527,7 +527,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
"version": 1,
"is_active": true,
"coverage_percent": 70,
"franchise_rials": 0,
"franchise_percent": 0,
"annual_ceiling_rials": null,
"kind": "basic",
"effective_from": 1718900000,
@@ -545,7 +545,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve |
| `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) |
| `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است |
| `franchise_rials` | فقط در قرارداد `supplementary` معنا دارد |
| `franchise_percent` | فقط در قرارداد `supplementary` معنا دارد |
### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.
@@ -555,7 +555,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
|------|-----|-------|
| `insurance_id` | int | الزامی |
| `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام می‌فرستد |
| `franchise_rials` | int | فرانشیز — فقط در قرارداد `supplementary` اثر دارد |
| `franchise_percent` | float | فرانشیز **درصدی** (۰ تا ۱۰۰) — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد `supplementary` اثر دارد. خارج از بازه → `422` با `field: franchise_percent` |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
@@ -563,6 +563,17 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیش‌فرض مرکزی ادمین می‌ماند (fallback زنده) |
| `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` |
| `category_coverages[].coverage_percent` | number \| null | ۰ تا ۱۰۰؛ `null` → override آن نوع حذف و به پیش‌فرض ادمین برمی‌گردد |
**اجبار درصد برای نوع خدمتِ فعال:** با ارسال `category_coverages`، هر نوع خدمتی که در تنظیمات
همین tenant فعال است باید درصد مؤثر بزرگ‌تر از صفر داشته باشد — از خود payload، از override
قبلی، یا از پیش‌فرض مرکزی ادمین. ستون قدیمی `coverage_percent` قرارداد اینجا fallback حساب
نمی‌شود، وگرنه نوع خدمتی که درصدش نیامده بی‌صدا نرخ نوع دیگر را ارث می‌برد.
```json
{ "success": false, "errors": [
{ "code": "ERR_VALIDATION_001", "field": "category_coverages", "message": "درصد پوشش خدمات بستری الزامی است" }
] }
```
| `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) |
```json
@@ -582,7 +593,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری.
**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
**Body:** `coverage_percent` · `franchise_percent` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).
- قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.
@@ -623,7 +634,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
"service_item_uuid": "…",
"covered": true,
"coverage_percent": 80,
"franchise_rials": null,
"franchise_percent": null,
"ceiling_rials": null
}
]
@@ -641,7 +652,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
| `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی |
| `covered` | bool | پیش‌فرض true |
| `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد |
| `franchise_percent` | int \| null | null = ارث از قرارداد |
| `ceiling_rials` | int \| null | null = ارث از قرارداد |
| `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) |
@@ -107,7 +107,7 @@ final readonly class CoverageRule
| version | int | نسخه‌ی قرارداد (۱، ۲، …) |
| is_active | bool | غیرفعال بدون حذف |
| coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد |
| franchise_rials | int | فرانشیز پیش‌فرض |
| franchise_percent | decimal(5,2) | فرانشیز **درصدی** — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد |
| annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه |
| effective_from | int (unix) | شروع اعتبار قرارداد |
| effective_to | int null | پایان (null = جاری) |
@@ -125,7 +125,7 @@ override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا
| service_item_id | int FK→service_items |
| covered | bool |
| coverage_percent | decimal(5,2) null (null=ارث از قرارداد) |
| franchise_rials | int null |
| franchise_percent | decimal(5,2) null (null=ارث از قرارداد) |
| ceiling_rials | int null |
UniqueConstraint: `(tenant_insurance_id, service_item_id)`.
@@ -176,10 +176,6 @@ UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌ع
| tariff_rials | int (تعرفه‌ی واحد) |
| quantity | int |
| total_rials | int (= tariff × qty) |
| base_coverage_percent | decimal(5,2) |
| supp_coverage_percent | decimal(5,2) |
| franchise_rials | int |
| ceiling_rials | int null |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260729155428 extends AbstractMigration
{
public function getDescription(): string
{
return 'Let an appointment carry a supplementary insurance next to its basic one, so the confirm step can bill both in chain.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE appointments ADD insurance_supplementary_id INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE appointments DROP insurance_supplementary_id');
}
}
+1 -7
View File
@@ -84,16 +84,10 @@ parameters:
count: 1
path: src/Billing/Controller/BillingController.php
-
message: '#^Using nullsafe property access "\?\-\>franchiseRials" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Billing/Service/BillingCalculator.php
-
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected
count: 1
count: 5
path: src/Billing/Service/ClaimService.php
-
@@ -1059,6 +1059,7 @@ class AppointmentController extends BaseController
// تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده.
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
'insurance_base_id' => $session->getInsuranceBaseId(),
'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
'gross_total_rials' => $session->getGrossTotalRials(),
'base_insurance_rials' => $session->getBaseInsuranceRials(),
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
+7
View File
@@ -195,6 +195,10 @@ class Appointment
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
/** بیمهٔ تکمیلی روی باقیماندهٔ بعد از بیمهٔ پایه محاسبه می‌شود، نه روی کل مبلغ. */
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
/**
* Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
* slotStart/slotEnd hold that day's midnight so date queries keep working.
@@ -291,6 +295,7 @@ class Appointment
public function getVisitPriceRials(): ?int { return $this->visitPriceRials; }
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function isReserve(): bool { return $this->isReserve; }
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
@@ -301,6 +306,7 @@ class Appointment
public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; }
public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceSupplementaryId(?int $v): self { $this->insuranceSupplementaryId = $v; $this->updatedAt = time(); return $this; }
/**
* Move the appointment to a new slot (جا به جایی نوبت) and/or flip its
@@ -422,6 +428,7 @@ class Appointment
'insurance_service_category' => $this->insuranceServiceCategory?->value,
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'is_reserve' => $this->isReserve,
'version' => $this->version,
'created_at' => $this->createdAt,
@@ -73,7 +73,13 @@ class AppointmentInsuranceService
if (array_key_exists('insurance_base_id', $data)) {
$appointment->setInsuranceBaseId(
$this->resolveBaseInsuranceId($data['insurance_base_id'], $entityType, $entityId)
$this->resolveInsuranceId($data['insurance_base_id'], $entityType, $entityId, false)
);
}
if (array_key_exists('insurance_supplementary_id', $data)) {
$appointment->setInsuranceSupplementaryId(
$this->resolveInsuranceId($data['insurance_supplementary_id'], $entityType, $entityId, true)
);
}
}
@@ -106,8 +112,15 @@ class AppointmentInsuranceService
return $category;
}
private function resolveBaseInsuranceId(mixed $raw, string $entityType, int $entityId): ?int
/**
* شناسهٔ بیمهٔ معتبر برای این نوبت، یا null وقتی انتخاب پاک شده است.
* قرارداد باید فعال باشد و نوعش با جایگاهی که در آن انتخاب شده بخواند —
* بیمهٔ تکمیلی نمی‌تواند جای بیمهٔ پایه بنشیند و برعکس.
*/
private function resolveInsuranceId(mixed $raw, string $entityType, int $entityId, bool $supplementary): ?int
{
$field = $supplementary ? 'insurance_supplementary_id' : 'insurance_base_id';
if ($raw === null || $raw === '' || (int) $raw <= 0) {
return null;
}
@@ -119,18 +132,20 @@ class AppointmentInsuranceService
ErrorCodes::ERR_VALIDATION_001,
'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد',
422,
'insurance_base_id',
$field,
);
}
// نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService.
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
if ($kind === InsuranceType::Supplementary->value) {
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
$isSupp = $kind === InsuranceType::Supplementary->value;
if ($isSupp !== $supplementary) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای نوبت فقط بیمهٔ پایه قابل انتخاب است',
$supplementary ? 'اینجا فقط بیمهٔ تکمیلی قابل انتخاب است' : 'اینجا فقط بیمهٔ پایه قابل انتخاب است',
422,
'insurance_base_id',
$field,
);
}
@@ -0,0 +1,74 @@
<?php
namespace App\Billing\Command;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* صورتحساب‌های نهایی‌شده‌ی بیمه‌داری که مطالبه‌شان ساخته نشده را جبران می‌کند.
*
* تا پیش از رویداد InvoiceFinalized، مطالبه فقط در مسیر «ثبت مراجعه» ساخته می‌شد؛
* صورتحسابی که از صفحه‌ی صورتحساب یا با ویرایش بعدی نهایی شده بود بدون مطالبه می‌ماند
* و در داشبورد مطالبات اصلاً دیده نمی‌شد.
*
* ddev exec php bin/console app:billing:backfill-claims --dry-run
*/
#[AsCommand(
name: 'app:billing:backfill-claims',
description: 'Create the missing claims of already finalized, insured invoices',
)]
class BackfillMissingClaimsCommand extends Command
{
public function __construct(
private readonly InvoiceRepository $invoiceRepo,
private readonly ClaimService $claimService,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی نساز');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$invoices = $this->invoiceRepo->findFinalizedInsuredWithoutClaims();
if ($invoices === []) {
$io->success('همه‌ی صورتحساب‌های بیمه‌دار مطالبه دارند.');
return Command::SUCCESS;
}
$rows = [];
$created = 0;
foreach ($invoices as $invoice) {
$claims = $dryRun ? [] : $this->claimService->syncFromInvoice($invoice);
$created += count($claims);
$rows[] = [
$invoice->getUuid(),
$invoice->getEntityType() . '#' . $invoice->getEntityId(),
number_format($invoice->getTotalRials()),
number_format($invoice->getTotalRials() - $invoice->getPatientRials()),
$dryRun ? '—' : count($claims),
];
}
$io->table(['صورتحساب', 'محیط', 'کل', 'سهم بیمه', 'مطالبهٔ ساخته‌شده'], $rows);
$io->success($dryRun
? sprintf('%d صورتحساب بدون مطالبه پیدا شد (dry-run).', count($invoices))
: sprintf('%d مطالبه برای %d صورتحساب ساخته شد.', $created, count($invoices)));
return Command::SUCCESS;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Billing\Event;
use App\Billing\Entity\Invoice;
/**
* صورتحساب نهایی شد. مصرف‌کننده‌ها اثرات جانبیِ نهایی‌شدن (مثل ساخت مطالبهٔ بیمه) را
* از اینجا می‌گیرند تا هر مسیری که صورتحساب را نهایی می‌کند خودش مجبور نباشد آن‌ها را
* تکرار کند — همان چیزی که نبودش باعث می‌شد صورتحساب‌های بیمه‌دار بدون مطالبه بمانند.
*/
final readonly class InvoiceFinalized
{
public function __construct(public Invoice $invoice) {}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Billing\EventSubscriber;
use App\Billing\Event\InvoiceFinalized;
use App\Billing\Service\ClaimService;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* هر صورتحساب نهایی‌شده‌ی بیمه‌دار باید مطالبه داشته باشد، از هر مسیری که نهایی شده باشد.
* خطا در این مرحله نباید نهایی‌شدن صورتحساب را برگرداند؛ فقط لاگ می‌شود.
*/
#[AsEventListener(event: InvoiceFinalized::class)]
class CreateClaimsOnInvoiceFinalized
{
public function __construct(
private readonly ClaimService $claimService,
private readonly LoggerInterface $logger,
) {}
public function __invoke(InvoiceFinalized $event): void
{
try {
$this->claimService->syncFromInvoice($event->invoice);
} catch (\Throwable $e) {
$this->logger->error('claim sync failed', [
'invoice' => $event->invoice->getUuid(),
'error' => $e->getMessage(),
]);
}
}
}
@@ -390,6 +390,26 @@ class ClaimRepository extends ServiceEntityRepository
return $count > 0;
}
/**
* نوع مطالبه‌هایی که برای این صورتحساب از قبل ثبت شده‌اند (`base` / `supplementary`).
* مبنای idempotent بودنِ ساخت خودکار مطالبه است.
*
* @return list<string>
*/
public function kindsForInvoice(int $invoiceId): array
{
$rows = $this->createQueryBuilder('c')
->select('DISTINCT c.insuranceKind AS kind')
->join('c.items', 'ci')
->join('App\Billing\Entity\InvoiceItem', 'ii', 'WITH', 'ii.id = ci.invoiceItemId')
->where('IDENTITY(ii.invoice) = :invoiceId')
->setParameter('invoiceId', $invoiceId)
->getQuery()
->getArrayResult();
return array_column($rows, 'kind');
}
public function save(Claim $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -29,6 +29,28 @@ class InvoiceRepository extends ServiceEntityRepository
return $this->findOneBy(['patientSessionId' => $patientSessionId]);
}
/**
* صورتحساب‌های نهایی‌شده‌ای که بیمه‌ای روی خودشان دارند ولی هیچ مطالبه‌ای برایشان
* ثبت نشده — بازمانده‌های دورانی که مطالبه فقط در یک مسیر ساخته می‌شد.
*
* @return Invoice[]
*/
public function findFinalizedInsuredWithoutClaims(): array
{
return $this->createQueryBuilder('i')
->where('i.status = :finalized')
->andWhere('i.baseInsuranceId IS NOT NULL OR i.supplementaryInsuranceId IS NOT NULL')
->andWhere('NOT EXISTS (
SELECT 1 FROM App\Billing\Entity\ClaimItem ci
JOIN App\Billing\Entity\InvoiceItem ii WITH ii.id = ci.invoiceItemId
WHERE IDENTITY(ii.invoice) = i.id
)')
->setParameter('finalized', Invoice::STATUS_FINALIZED)
->orderBy('i.id', 'ASC')
->getQuery()
->getResult();
}
/**
* A flat, newest-first page of a tenant's recorded (finalized/paid) invoices,
* one row per invoice with the patient's name and national code joined in.
+47
View File
@@ -72,6 +72,53 @@ class ClaimService
return $claims;
}
/**
* مطالبات جاافتاده‌ی یک صورتحساب نهایی‌شده را می‌سازد و مطالبات موجود را دست نمی‌زند.
* برخلاف createFromInvoice که یک عملِ کاربر است و با خطا جواب می‌دهد، این متد
* روی رویدادِ نهایی‌شدن صدا زده می‌شود و «کاری برای انجام نبود» حالت عادی‌اش است.
*
* @return Claim[] مطالبات تازه‌ساخته‌شده
*/
public function syncFromInvoice(Invoice $invoice): array
{
if ($invoice->getStatus() !== Invoice::STATUS_FINALIZED || $invoice->getId() === null) {
return [];
}
$existingKinds = $this->claimRepo->kindsForInvoice($invoice->getId());
$claims = [];
foreach ([
Claim::KIND_BASE => $invoice->getBaseInsuranceId(),
Claim::KIND_SUPPLEMENTARY => $invoice->getSupplementaryInsuranceId(),
] as $kind => $insuranceId) {
if ($insuranceId === null || in_array($kind, $existingKinds, true)) {
continue;
}
$claim = $this->buildClaim($invoice, $insuranceId, $kind);
if ($claim !== null) {
$claims[] = $claim;
}
}
if ($claims === []) {
return [];
}
foreach ($claims as $claim) {
$this->claimRepo->save($claim, false);
}
$this->claimRepo->getEntityManager()->flush();
foreach ($claims as $claim) {
$this->logTransition($claim, null, $claim->getStatus(), 'ایجاد مطالبه', null);
}
$this->claimRepo->getEntityManager()->flush();
return $claims;
}
private function buildClaim(Invoice $invoice, int $insuranceId, string $kind): ?Claim
{
$claim = new Claim($invoice->getEntityType(), $invoice->getEntityId(), $insuranceId, $kind);
+6
View File
@@ -4,6 +4,7 @@ namespace App\Billing\Service;
use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\Event\InvoiceFinalized;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\ValueObject\Money;
use App\ClinicService\Service\TariffService;
@@ -12,6 +13,7 @@ use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Entity\PatientSession;
use App\Patient\Repository\PatientSessionRepository;
use Psr\EventDispatcher\EventDispatcherInterface;
class InvoiceService
{
@@ -22,6 +24,7 @@ class InvoiceService
private readonly BillingCalculator $calculator,
private readonly PatientSessionRepository $sessionRepo,
private readonly InsuranceRepository $insuranceRepo,
private readonly EventDispatcherInterface $events,
) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
@@ -89,10 +92,13 @@ class InvoiceService
return $invoice;
}
/** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */
public function finalize(Invoice $invoice): void
{
$invoice->finalize();
$this->invoiceRepo->save($invoice);
$this->events->dispatch(new InvoiceFinalized($invoice));
}
/**
@@ -0,0 +1,48 @@
<?php
namespace App\Billing\Service;
use App\Billing\Entity\Invoice;
use App\Patient\Entity\PatientSession;
use Psr\Log\LoggerInterface;
/**
* مراجعهٔ بیمه‌دار باید صورتحساب نهایی‌شده داشته باشد — چون مطالبهٔ بیمه از دل همان
* صورتحساب بیرون می‌آید و بدونش، مراجعه در داشبورد مطالبات اصلاً دیده نمی‌شود.
*
* تنها جای این قاعده همین‌جاست تا هر سه مسیرِ ساختِ مراجعه (ثبت دستی، ویرایش، و
* قطعی‌کردن نوبت) یک رفتار داشته باشند؛ پراکندگیِ قبلیِ همین قاعده باعث شده بود
* مراجعه‌های قطعی‌شده از نوبت هیچ‌وقت مطالبه نگیرند.
*/
class SessionBillingService
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly LoggerInterface $logger,
) {}
/**
* صورتحساب مراجعه را می‌سازد و نهایی می‌کند. مراجعهٔ بدون بیمه دست‌نخورده می‌ماند.
* شکست اینجا نباید ثبت مراجعه یا قطعی‌کردن نوبت را برگرداند.
*/
public function ensureFinalizedInvoice(PatientSession $session, string $entityType, int $entityId): ?Invoice
{
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
return null;
}
try {
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
$this->invoiceService->finalize($invoice);
return $invoice;
} catch (\Throwable $e) {
$this->logger->error('finalizing the insured session invoice failed', [
'session' => $session->getUuid(),
'error' => $e->getMessage(),
]);
return null;
}
}
}
+7 -30
View File
@@ -5,9 +5,7 @@ namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
use Psr\Log\LoggerInterface;
use App\Billing\Service\SessionBillingService;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
@@ -40,8 +38,7 @@ class PatientController extends BaseController
private readonly PatientRecordScopeResolver $scopeResolver,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly InvoiceService $invoiceService,
private readonly ClaimService $claimService,
private readonly SessionBillingService $sessionBilling,
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
@@ -60,7 +57,6 @@ class PatientController extends BaseController
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly LoggerInterface $logger,
) {}
// ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ────────────────────────────
@@ -1058,34 +1054,11 @@ class PatientController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
$this->autoCreateClaim($session, $entityType, $entityId);
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
return $this->success($this->sessionWithBilling($session), 201);
}
/**
* اتصال خودکار مطالبه‌ی بیمه: اگر session بیمه‌ی پایه یا مکمل داشته باشد،
* صورتحساب ساخته و نهایی می‌شود و مطالبه (در وضعیت pending) ایجاد می‌گردد.
* ارسال مطالبه دستی از صفحه‌ی مطالبات انجام می‌شود.
* خطا در این مرحله نباید ثبت session را خراب کند.
*/
private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void
{
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
return;
}
try {
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
$this->invoiceService->finalize($invoice);
$this->claimService->createFromInvoice($invoice);
} catch (\App\Shared\Exception\AppException $e) {
// سهم بیمه‌ای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشم‌پوشی.
$this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]);
} catch (\Throwable $e) {
$this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]);
}
}
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -1112,6 +1085,10 @@ class PatientController extends BaseController
|| array_key_exists('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data)
|| array_key_exists('base_insurance_discount_percent', $data)) {
$this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user);
// بیمه ممکن است همین حالا به مراجعه اضافه شده باشد؛ بدون این، مراجعه‌ای که
// بیمه‌اش بعداً ثبت می‌شود هرگز صورتحساب و مطالبه نمی‌گیرد.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
}
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
+10 -1
View File
@@ -52,6 +52,7 @@ class PatientService
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator,
private readonly \App\Billing\Service\SessionBillingService $sessionBilling,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
@@ -238,9 +239,13 @@ class PatientService
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
);
$session->setSupplementaryDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category)
);
$shares = $this->calculateFinalPrice(
$visitPrice,
@@ -251,7 +256,7 @@ class PatientService
$entityType,
$entityId,
$appointment->getInsuranceBaseId(),
null,
$appointment->getInsuranceSupplementaryId(),
$category,
);
@@ -269,6 +274,10 @@ class PatientService
$session->addService($line);
}
// مراجعهٔ بیمه‌دارِ آمده از نوبت هم باید صورتحساب و مطالبه بگیرد؛ بدون این،
// نوبت‌های قطعی‌شده هرگز به داشبورد مطالبات نمی‌رسیدند.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
return $session;
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Tests\Billing;
use App\Auth\Entity\User;
use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\Repository\ClaimRepository;
use App\Billing\Service\InvoiceService;
use App\Billing\ValueObject\ShareBreakdown;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* مطالبهٔ بیمه اثر جانبیِ نهایی‌شدن صورتحساب است، نه کارِ یک endpoint خاص.
* پیش از این فقط `POST /api/v1/patient/{uuid}/session` مطالبه می‌ساخت، و هر
* صورتحسابی که از مسیر دیگری نهایی می‌شد بی‌صدا بدون مطالبه می‌ماند.
*/
class ClaimAutoCreationTest extends ApiTestCase
{
private Doctor $doctor;
private PatientRecord $record;
protected function setUp(): void
{
parent::setUp();
$owner = $this->createUser(['ROLE_DOCTOR']);
$this->doctor = new Doctor($owner, 'دکتر مطالبه');
$this->em->persist($this->doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$this->record = new PatientRecord('doctor', $this->doctor->getId(), $patient, 'doctor', $this->doctor->getId());
$this->em->persist($this->record);
$this->em->flush();
}
/** صورتحساب draft با سهم بیمهٔ پایه/تکمیلی روی تنها آیتمش. */
private function draftInvoice(?int $baseId, ?int $suppId, int $baseShare, int $suppShare): Invoice
{
$total = 10_000_000;
$invoice = new Invoice('doctor', $this->doctor->getId());
$invoice->setPatientRecordId((int) $this->record->getId())
->setBaseInsuranceId($baseId)
->setSupplementaryInsuranceId($suppId);
$item = new InvoiceItem(
$invoice,
'ویزیت',
$total,
1,
new ShareBreakdown($total, $baseShare, $suppShare, $total - $baseShare - $suppShare),
);
$invoice->addItem($item);
$invoice->recalculateTotals();
$this->em->persist($invoice);
$this->em->persist($item);
$this->em->flush();
return $invoice;
}
private function claimRepo(): ClaimRepository
{
return static::getContainer()->get(ClaimRepository::class);
}
private function invoiceService(): InvoiceService
{
return static::getContainer()->get(InvoiceService::class);
}
public function testFinalizingAnInsuredInvoiceCreatesItsClaims(): void
{
$invoice = $this->draftInvoice(1, 2, 6_000_000, 2_000_000);
$this->invoiceService()->finalize($invoice);
$kinds = $this->claimRepo()->kindsForInvoice((int) $invoice->getId());
sort($kinds);
self::assertSame(['base', 'supplementary'], $kinds);
}
public function testASupplementaryOnlyInvoiceStillGetsItsClaim(): void
{
// همان حالتی که در پنل دیده شد: بیمار بیمهٔ پایه ندارد، فقط تکمیلی.
$invoice = $this->draftInvoice(null, 2, 0, 4_760_000);
$this->invoiceService()->finalize($invoice);
self::assertSame(['supplementary'], $this->claimRepo()->kindsForInvoice((int) $invoice->getId()));
}
public function testFinalizingTwiceDoesNotDuplicateClaims(): void
{
$invoice = $this->draftInvoice(1, null, 3_000_000, 0);
$this->invoiceService()->finalize($invoice);
$this->invoiceService()->finalize($invoice);
self::assertCount(1, $this->claimRepo()->kindsForInvoice((int) $invoice->getId()));
}
public function testAnInvoiceWithoutAnyInsuranceShareGetsNoClaim(): void
{
$invoice = $this->draftInvoice(null, null, 0, 0);
$this->invoiceService()->finalize($invoice);
self::assertSame([], $this->claimRepo()->kindsForInvoice((int) $invoice->getId()));
}
/** بیمه‌ای که فقط روی صورتحساب ثبت شده ولی سهمی نبرده، مطالبه نمی‌سازد. */
public function testAZeroShareInsuranceIsNotClaimed(): void
{
$invoice = $this->draftInvoice(1, 2, 6_000_000, 0);
$this->invoiceService()->finalize($invoice);
self::assertSame(['base'], $this->claimRepo()->kindsForInvoice((int) $invoice->getId()));
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Tests\Patient;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentInsuranceService;
use App\Billing\Repository\ClaimRepository;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Service\PatientService;
use App\Tests\ApiTestCase;
/**
* بیمهٔ تکمیلی هم مثل پایه روی خودِ نوبت انتخاب می‌شود، و در مراجعه‌ای که از قطعی‌کردن
* ساخته می‌شود زنجیره‌ای اعمال می‌گردد: اول پایه روی کل، بعد تکمیلی روی باقیمانده.
*/
class AppointmentSupplementaryInsuranceTest extends ApiTestCase
{
private Doctor $doctor;
private Insurance $basic;
private Insurance $supplementary;
protected function setUp(): void
{
parent::setUp();
$this->doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر بیمه تکمیلی');
$this->em->persist($this->doctor);
$this->basic = new Insurance('پایهٔ تست ' . random_int(1000, 9999), InsuranceType::Basic);
$this->supplementary = new Insurance('تکمیلی تست ' . random_int(1000, 9999), InsuranceType::Supplementary);
$this->em->persist($this->basic);
$this->em->persist($this->supplementary);
$this->em->flush();
}
private function contract(Insurance $insurance, float $percent, float $franchise = 0.0): TenantInsurance
{
$service = static::getContainer()->get(TenantInsuranceService::class);
$contract = $service->activate(
TenantInsurance::TYPE_DOCTOR,
(int) $this->doctor->getId(),
(int) $insurance->getId(),
$percent,
$franchise,
);
$service->setCategoryCoverages($contract, [
['key' => 'outpatient', 'coverage_percent' => $percent],
['key' => 'inpatient', 'coverage_percent' => $percent],
]);
return $contract;
}
private function appointmentWithVisitPrice(int $rials): Appointment
{
$appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800);
$appointment->setVisitPriceRials($rials);
$appointment->setInsuranceServiceCategory(ServiceCategory::Outpatient);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
public function testTheSessionBillsBaseThenSupplementaryOnTheRemainder(): void
{
$this->contract($this->basic, 30.0);
$this->contract($this->supplementary, 90.0, 10.0);
$appointment = $this->appointmentWithVisitPrice(10_000_000);
static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [
'insurance_base_id' => $this->basic->getId(),
'insurance_supplementary_id' => $this->supplementary->getId(),
]);
$this->em->flush();
$session = static::getContainer()->get(PatientService::class)->autoCreateOnAppointmentConfirm($appointment);
// پایه ۳۰٪ از ۱۰,۰۰۰,۰۰۰ → ۳,۰۰۰,۰۰۰؛ باقیمانده ۷,۰۰۰,۰۰۰؛
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۵,۶۰۰,۰۰۰؛ سهم بیمار ۱,۴۰۰,۰۰۰.
self::assertSame((int) $this->supplementary->getId(), $session->getInsuranceSupplementaryId());
self::assertSame(3_000_000, $session->getBaseInsuranceRials());
self::assertSame(5_600_000, $session->getSupplementaryInsuranceRials());
self::assertSame(1_400_000, $session->getPatientShareRials());
self::assertSame(
$session->getGrossTotalRials(),
$session->getBaseInsuranceRials() + $session->getSupplementaryInsuranceRials() + $session->getPatientShareRials(),
);
}
public function testASupplementaryInsuranceCannotBePickedAsTheBasicOne(): void
{
$this->contract($this->supplementary, 90.0);
$appointment = $this->appointmentWithVisitPrice(1_000_000);
$this->expectExceptionMessage('اینجا فقط بیمهٔ پایه قابل انتخاب است');
static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [
'insurance_base_id' => $this->supplementary->getId(),
]);
}
public function testABasicInsuranceCannotBePickedAsTheSupplementaryOne(): void
{
$this->contract($this->basic, 30.0);
$appointment = $this->appointmentWithVisitPrice(1_000_000);
$this->expectExceptionMessage('اینجا فقط بیمهٔ تکمیلی قابل انتخاب است');
static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [
'insurance_supplementary_id' => $this->basic->getId(),
]);
}
public function testAnInsuranceWithoutAnActiveContractIsRefused(): void
{
$appointment = $this->appointmentWithVisitPrice(1_000_000);
$this->expectExceptionMessage('این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد');
static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [
'insurance_supplementary_id' => $this->supplementary->getId(),
]);
}
/** مراجعهٔ تکمیلی‌دار باید مطالبهٔ تکمیلی هم بگیرد، نه فقط پایه. */
public function testTheConfirmedVisitProducesBothClaims(): void
{
$this->contract($this->basic, 30.0);
$this->contract($this->supplementary, 90.0, 10.0);
$appointment = $this->appointmentWithVisitPrice(10_000_000);
static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [
'insurance_base_id' => $this->basic->getId(),
'insurance_supplementary_id' => $this->supplementary->getId(),
]);
$this->em->flush();
// قطعی‌کردن به‌تنهایی باید صورتحساب و مطالبه بسازد — بدون هیچ گام دستی.
$session = static::getContainer()->get(PatientService::class)->autoCreateOnAppointmentConfirm($appointment);
$invoice = static::getContainer()->get(\App\Billing\Repository\InvoiceRepository::class)
->findBySession((int) $session->getId());
self::assertNotNull($invoice, 'مراجعهٔ بیمه‌دارِ آمده از نوبت باید صورتحساب داشته باشد');
$kinds = static::getContainer()->get(ClaimRepository::class)->kindsForInvoice((int) $invoice->getId());
sort($kinds);
self::assertSame(['base', 'supplementary'], $kinds);
}
}