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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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): مقدار از خود قانون، با ثبت منبع.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user