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: [], 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( function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[], categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0, freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
) { ) {
get.mockImplementation((url: string) => { get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') { if (url === '/api/v1/insurance-pricing') {
@@ -148,7 +155,7 @@ function mockInsurance(
}); });
} }
if (url === '/api/v1/billing/tenant-insurances') { 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: [] }); return Promise.resolve({ success: true, data: [] });
}); });
@@ -180,7 +187,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
renderReference(); renderReference();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه')).toBeInTheDocument(); expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
}); });
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => { it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
@@ -190,7 +197,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
]); ]);
renderReference(); renderReference();
expect(await screen.findByText('بیمه')).toBeInTheDocument(); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.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 () => { it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000); mockInsurance(BOTH, 5_952_000);
renderWithProviders( renderWithProviders(
@@ -36,6 +36,7 @@ interface AppointmentLike {
patient_name?: string | null; patient_name?: string | null;
insurance_service_category?: string | null; insurance_service_category?: string | null;
insurance_base_id?: number | null; insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
} }
interface Props { interface Props {
@@ -148,12 +149,14 @@ export default function ConfirmAppointmentModal({
const total = visitPrice + servicesTotal; const total = visitPrice + servicesTotal;
const [serviceCategory, setServiceCategory] = useState<string>(''); const [serviceCategory, setServiceCategory] = useState<string>('');
const [insuranceId, setInsuranceId] = useState<string>(''); const [insuranceId, setInsuranceId] = useState<string>('');
const [supplementaryId, setSupplementaryId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant. // مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? ''); setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : ''); 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]); }, [open, appt?.uuid, insurance.defaultCategory]);
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
@@ -166,9 +169,10 @@ export default function ConfirmAppointmentModal({
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY, category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
insured: s.insurance_covered !== false, 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 ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است. // نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است.
@@ -196,6 +200,7 @@ export default function ConfirmAppointmentModal({
version: appt?.version, version: appt?.version,
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}), ...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}), ...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}),
payments: rows payments: rows
.filter(r => tomanToRial(r.amountToman) > 0) .filter(r => tomanToRial(r.amountToman) > 0)
.map(r => ({ .map(r => ({
@@ -305,7 +310,7 @@ export default function ConfirmAppointmentModal({
</div> </div>
)} )}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}> <div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label> <label>بیمه پایه</label>
<SearchableSelect <SearchableSelect
value={insuranceId} value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))} onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
@@ -316,6 +321,19 @@ export default function ConfirmAppointmentModal({
height={40} height={40}
/> />
</div> </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> </div>
{/* هزینه‌ها */} {/* هزینه‌ها */}
@@ -339,10 +357,26 @@ export default function ConfirmAppointmentModal({
<span>جمع کل</span> <span>جمع کل</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong> <strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
</div> </div>
{/* سهم‌ها زنجیره‌ای‌اند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده
می‌شوند تا معلوم باشد هرکدام چقدر برداشته‌اند. */}
{insuranceId !== '' && ( {insuranceId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}> <div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span> <span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong> سهم بیمه پایه
{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>
)} )}
<div <div
@@ -351,7 +385,7 @@ export default function ConfirmAppointmentModal({
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)', 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> <strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div> </div>
</div> </div>
+25 -9
View File
@@ -47,10 +47,18 @@ export function useAppointmentInsurance(enabled: boolean) {
[contractsQuery.data], [contractsQuery.data],
); );
const activeContracts = useMemo(() => contracts.filter((c) => c.is_active !== false), [contracts]);
const basicContracts = useMemo( const basicContracts = useMemo(
() => contracts.filter((c) => c.is_active !== false && c.insurance_kind !== 'supplementary'), () => activeContracts.filter((c) => c.insurance_kind !== 'supplementary'),
[contracts], [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 { return {
/** /**
@@ -70,18 +78,26 @@ export function useAppointmentInsurance(enabled: boolean) {
categoryLabelOf: (key: string | null | undefined) => categoryLabelOf: (key: string | null | undefined) =>
(pricing.service_categories ?? []).find((c) => c.key === key)?.label ?? null, (pricing.service_categories ?? []).find((c) => c.key === key)?.label ?? null,
insuranceOptions: basicContracts.map((c) => ({ insuranceOptions: optionsOf(basicContracts),
value: String(c.insurance_id), /** قراردادهای تکمیلیِ فعال — روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شوند. */
label: c.insurance_name ?? `#${c.insurance_id}`, supplementaryOptions: optionsOf(supplementaryContracts),
})), hasSupplementary: supplementaryContracts.length > 0,
contractOf: (insuranceId: string | number | null | undefined): TenantContract | null => contractOf: (insuranceId: string | number | null | undefined): TenantContract | null =>
basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null, basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null,
insuranceNameOf: (insuranceId: string | number | null | undefined): string | null => insuranceNameOf: (insuranceId: string | number | null | undefined): string | null =>
contracts.find((c) => String(c.insurance_id) === String(insuranceId))?.insurance_name ?? null, contracts.find((c) => String(c.insurance_id) === String(insuranceId))?.insurance_name ?? null,
/** تفکیک سهم بیمه/بیمار برای ردیف‌های داده‌شده تحت قرارداد یک بیمه. */ /** تفکیک زنجیره‌ای سهم‌ها: پایه روی کل، تکمیلی روی باقیمانده. */
breakdown: (lines: BillableLine[], insuranceId: string | number | null | undefined): ShareBreakdown => breakdown: (
breakdownOf(lines, basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null), 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, isLoading: pricingQuery.isLoading || contractsQuery.isLoading,
}; };
+39 -19
View File
@@ -27,19 +27,25 @@ export interface TenantContract {
/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */ /** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */
export const DEFAULT_SERVICE_CATEGORY = 'outpatient'; 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 { export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
let baseShare = 0; const baseShare = baseShareOf(total, base);
let remaining = total; const remaining = total - baseShare;
if (base && base.covered) {
baseShare = Math.round(total * (base.percent / 100));
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
remaining = total - baseShare;
}
let suppShare = 0; let suppShare = 0;
if (supp && supp.covered) { if (supp && supp.covered) {
suppShare = Math.max( suppShare = Math.max(
@@ -80,21 +86,35 @@ export interface BillableLine {
export interface ShareBreakdown { export interface ShareBreakdown {
total: number; total: number;
/** مجموع سهم هر دو بیمه — برای نمایشِ یک‌خطی. */
insurance: number; insurance: number;
base: number;
supplementary: number;
patient: 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) => { return lines.reduce<ShareBreakdown>((acc, line) => {
const patient = line.insured const baseRule = line.insured ? ruleOf(base, line.category) : null;
? patientShareOf(line.total, ruleOf(base, line.category), null) const suppRule = line.insured ? ruleOf(supplementary, line.category) : null;
: line.total;
const patient = line.insured ? patientShareOf(line.total, baseRule, suppRule) : line.total;
const baseShare = baseShareOf(line.total, baseRule);
return { return {
total: acc.total + line.total, total: acc.total + line.total,
insurance: acc.insurance + (line.total - patient), base: acc.base + baseShare,
patient: acc.patient + patient, 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` | 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_service_category_label` | string \| null | برچسب فارسی همان نوع |
| `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد | | `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد |
| `insurance_supplementary_id` | int \| null | بیمهٔ **تکمیلیِ** انتخاب‌شده؛ قرارداد فعال لازم دارد و روی **باقیماندهٔ بعد از بیمهٔ پایه** محاسبه می‌شود ([فرمول زنجیره‌ای](billing.md)) |
نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند. نام بیمه در این پاسخ نیست؛ پنل آن را از `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 | | `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. | | `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. |
| `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. | | `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. |
| `insurance_supplementary_id` | integer | ❌ | بیمهٔ تکمیلی، همان قواعد `PATCH`. روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شود. |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). | | `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). |
| `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` | | `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` |
| `payments[].amount_rials` | integer | ✅ | > 0 | | `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. A `PatientSession` is opened with the visit price and one line per attached service.
4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از 4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از
نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve نوع خدمتِ نوبت — یا تنها نوع فعالِ 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). 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`، پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`،
`supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`، `supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`،
`insurance_base_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است. `insurance_base_id`، `insurance_supplementary_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است.
### Response `200` ### Response `200`
```json ```json
@@ -603,18 +607,28 @@ collected later through `POST /api/v1/session/{uuid}/payments`.
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 }, "appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": { "session": {
"uuid": "…", "uuid": "…",
"visit_price_rials": 5000000, "visit_price_rials": 5950000,
"services_total_rials": 1500000, "services_total_rials": 0,
"final_price_rials": 6500000, "final_price_rials": 833000,
"discount_rials": 0, "discount_rials": 0,
"paid_total_rials": 5000000, "paid_total_rials": 0,
"remaining_rials": 1500000, "remaining_rials": 833000,
"is_paid": false "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 `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 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 `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`. - `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`. - Relation uuids: empty string clears; unknown uuid → `422`.
- `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`. - `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. - `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. - Optimistic lock via `version``409` on concurrent edit.
+51 -21
View File
@@ -12,20 +12,43 @@
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`). - قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی). - درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ 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` برمی‌گردد. - `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 | حداکثر ۱۰۰ | | `limit` | int | 20 | حداکثر ۱۰۰ |
| `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` | | `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` |
| `dir` | string | `desc` | `asc` \| `desc` | | `dir` | string | `desc` | `asc` \| `desc` |
| `search` | string | — | نام، موبایل یا کد ملی بیمار | | `search` | string | — | نام، موبایل، کد ملی بیمار **یا نام بیمه** |
| `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` | | `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` |
| `insurance_id` | int | — | شناسه بیمه | | `insurance_id` | int | — | شناسه بیمه |
| `kind` | string | — | نوع بیمه: `base` \| `supplementary` (واژگان مطالبه `base` است، نه `basic`). مقدار نامعتبر → `422 ERR_VALIDATION_001` با `field: kind` |
| `doctor_id` | int | — | پزشکِ نوبتِ مراجعه | | `doctor_id` | int | — | پزشکِ نوبتِ مراجعه |
| `payment_status` | string | — | `paid` (وصول‌شده) \| `unpaid` | | `payment_status` | string | — | `paid` (وصول‌شده) \| `unpaid` |
| `from` / `to` | int | — | بازه‌ی `claims.created_at` (unix ثانیه) | | `from` / `to` | int | — | بازه‌ی `claims.created_at` (unix ثانیه) |
@@ -230,25 +254,31 @@
"success": true, "success": true,
"data": [ "data": [
{ {
"patient_uuid": "45064492-...", "patient_uuid": "649e24ae-08e2-489d-b502-af07c59dfa2d",
"record_uuid": "ad0a3d0e-...", "record_uuid": "8e36035c-d234-42aa-858b-2427918e9c4a",
"full_name": "تست جراحی بینی", "full_name": "تست بیمه ۱",
"mobile": "09370671756", "mobile": "09243243534",
"national_code": null, "national_code": null,
"claims_count": 2, "claims_count": 3,
"total_services_rials": 81500000, "total_services_rials": 91900000,
"total_insurance_rials": 57050000, "total_insurance_rials": 9877000,
"total_patient_rials": 24450000, "total_patient_rials": 82023000,
"total_approved_rials": 28000000, "total_approved_rials": 0,
"total_paid_rials": 28000000, "total_paid_rials": 0,
"overall_status": "mixed", "overall_status": "pending",
"last_activity_at": 1784404115 "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`. - `overall_status`: اگر همه‌ی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`.
- ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`. - ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`.
- **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد. - **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد.
+18 -7
View File
@@ -33,7 +33,7 @@ hardcode نمی‌شود. ویزیت همیشه `outpatient` است.
| ۴ | `coverage_percent` قرارداد (سازگاری با ردیف‌های قدیمی) | `tenant_insurances` | | ۴ | `coverage_percent` قرارداد (سازگاری با ردیف‌های قدیمی) | `tenant_insurances` |
**fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیش‌فرض ادمین **fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیش‌فرض ادمین
خودبه‌خود به‌روز می‌شود. `franchise_rials` فقط در قراردادهای `supplementary` اثر دارد. خودبه‌خود به‌روز می‌شود. `franchise_percent` فقط در قراردادهای `supplementary` اثر دارد.
--- ---
@@ -527,7 +527,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
"version": 1, "version": 1,
"is_active": true, "is_active": true,
"coverage_percent": 70, "coverage_percent": 70,
"franchise_rials": 0, "franchise_percent": 0,
"annual_ceiling_rials": null, "annual_ceiling_rials": null,
"kind": "basic", "kind": "basic",
"effective_from": 1718900000, "effective_from": 1718900000,
@@ -545,7 +545,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve | | `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve |
| `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) | | `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) |
| `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است | | `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است |
| `franchise_rials` | فقط در قرارداد `supplementary` معنا دارد | | `franchise_percent` | فقط در قرارداد `supplementary` معنا دارد |
### POST `/api/v1/billing/tenant-insurances` ### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید. فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.
@@ -555,7 +555,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
|------|-----|-------| |------|-----|-------|
| `insurance_id` | int | الزامی | | `insurance_id` | int | الزامی |
| `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام می‌فرستد | | `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام می‌فرستد |
| `franchise_rials` | int | فرانشیز — فقط در قرارداد `supplementary` اثر دارد | | `franchise_percent` | float | فرانشیز **درصدی** (۰ تا ۱۰۰) — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد `supplementary` اثر دارد. خارج از بازه → `422` با `field: franchise_percent` |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) | | `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ | | `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون | | `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
@@ -563,6 +563,17 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیش‌فرض مرکزی ادمین می‌ماند (fallback زنده) | | `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیش‌فرض مرکزی ادمین می‌ماند (fallback زنده) |
| `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` | | `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` |
| `category_coverages[].coverage_percent` | number \| null | ۰ تا ۱۰۰؛ `null` → override آن نوع حذف و به پیش‌فرض ادمین برمی‌گردد | | `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» بالا) | | `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) |
```json ```json
@@ -582,7 +593,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
### PATCH `/api/v1/billing/tenant-insurances/{uuid}` ### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری. ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به 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). - `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).
- قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`. - قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.
@@ -623,7 +634,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
"service_item_uuid": "…", "service_item_uuid": "…",
"covered": true, "covered": true,
"coverage_percent": 80, "coverage_percent": 80,
"franchise_rials": null, "franchise_percent": null,
"ceiling_rials": null "ceiling_rials": null
} }
] ]
@@ -641,7 +652,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
| `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی | | `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی |
| `covered` | bool | پیش‌فرض true | | `covered` | bool | پیش‌فرض true |
| `coverage_percent` | float \| null | null = ارث از قرارداد | | `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد | | `franchise_percent` | int \| null | null = ارث از قرارداد |
| `ceiling_rials` | int \| null | null = ارث از قرارداد | | `ceiling_rials` | int \| null | null = ارث از قرارداد |
| `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) | | `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) |
@@ -107,7 +107,7 @@ final readonly class CoverageRule
| version | int | نسخه‌ی قرارداد (۱، ۲، …) | | version | int | نسخه‌ی قرارداد (۱، ۲، …) |
| is_active | bool | غیرفعال بدون حذف | | is_active | bool | غیرفعال بدون حذف |
| coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد | | coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد |
| franchise_rials | int | فرانشیز پیش‌فرض | | franchise_percent | decimal(5,2) | فرانشیز **درصدی** — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد |
| annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه | | annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه |
| effective_from | int (unix) | شروع اعتبار قرارداد | | effective_from | int (unix) | شروع اعتبار قرارداد |
| effective_to | int null | پایان (null = جاری) | | effective_to | int null | پایان (null = جاری) |
@@ -125,7 +125,7 @@ override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا
| service_item_id | int FK→service_items | | service_item_id | int FK→service_items |
| covered | bool | | covered | bool |
| coverage_percent | decimal(5,2) null (null=ارث از قرارداد) | | coverage_percent | decimal(5,2) null (null=ارث از قرارداد) |
| franchise_rials | int null | | franchise_percent | decimal(5,2) null (null=ارث از قرارداد) |
| ceiling_rials | int null | | ceiling_rials | int null |
UniqueConstraint: `(tenant_insurance_id, service_item_id)`. UniqueConstraint: `(tenant_insurance_id, service_item_id)`.
@@ -176,10 +176,6 @@ UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌ع
| tariff_rials | int (تعرفه‌ی واحد) | | tariff_rials | int (تعرفه‌ی واحد) |
| quantity | int | | quantity | int |
| total_rials | int (= tariff × qty) | | 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 | | base_insurance_rials | int |
| supplementary_rials | int | | supplementary_rials | int |
| patient_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 count: 1
path: src/Billing/Controller/BillingController.php 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\>\.$#' message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected identifier: method.protected
count: 1 count: 5
path: src/Billing/Service/ClaimService.php path: src/Billing/Service/ClaimService.php
- -
@@ -1059,6 +1059,7 @@ class AppointmentController extends BaseController
// تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده. // تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده.
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value, 'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
'insurance_base_id' => $session->getInsuranceBaseId(), 'insurance_base_id' => $session->getInsuranceBaseId(),
'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
'gross_total_rials' => $session->getGrossTotalRials(), 'gross_total_rials' => $session->getGrossTotalRials(),
'base_insurance_rials' => $session->getBaseInsuranceRials(), 'base_insurance_rials' => $session->getBaseInsuranceRials(),
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(), 'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
+7
View File
@@ -195,6 +195,10 @@ class Appointment
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)] #[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null; 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. * Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
* slotStart/slotEnd hold that day's midnight so date queries keep working. * 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 getVisitPriceRials(): ?int { return $this->visitPriceRials; }
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; } public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; } public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function isReserve(): bool { return $this->isReserve; } public function isReserve(): bool { return $this->isReserve; }
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; } 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 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 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 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 * 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' => $this->insuranceServiceCategory?->value,
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(), 'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
'insurance_base_id' => $this->insuranceBaseId, 'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'is_reserve' => $this->isReserve, 'is_reserve' => $this->isReserve,
'version' => $this->version, 'version' => $this->version,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
@@ -73,7 +73,13 @@ class AppointmentInsuranceService
if (array_key_exists('insurance_base_id', $data)) { if (array_key_exists('insurance_base_id', $data)) {
$appointment->setInsuranceBaseId( $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; 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) { if ($raw === null || $raw === '' || (int) $raw <= 0) {
return null; return null;
} }
@@ -119,18 +132,20 @@ class AppointmentInsuranceService
ErrorCodes::ERR_VALIDATION_001, ErrorCodes::ERR_VALIDATION_001,
'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد', 'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد',
422, 422,
'insurance_base_id', $field,
); );
} }
// نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService. // نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService.
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value; $kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
if ($kind === InsuranceType::Supplementary->value) { $isSupp = $kind === InsuranceType::Supplementary->value;
if ($isSupp !== $supplementary) {
throw new AppException( throw new AppException(
ErrorCodes::ERR_VALIDATION_001, ErrorCodes::ERR_VALIDATION_001,
'برای نوبت فقط بیمهٔ پایه قابل انتخاب است', $supplementary ? 'اینجا فقط بیمهٔ تکمیلی قابل انتخاب است' : 'اینجا فقط بیمهٔ پایه قابل انتخاب است',
422, 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; 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 public function save(Claim $entity, bool $flush = true): void
{ {
$this->getEntityManager()->persist($entity); $this->getEntityManager()->persist($entity);
@@ -29,6 +29,28 @@ class InvoiceRepository extends ServiceEntityRepository
return $this->findOneBy(['patientSessionId' => $patientSessionId]); 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, * 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. * 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; 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 private function buildClaim(Invoice $invoice, int $insuranceId, string $kind): ?Claim
{ {
$claim = new Claim($invoice->getEntityType(), $invoice->getEntityId(), $insuranceId, $kind); $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\Invoice;
use App\Billing\Entity\InvoiceItem; use App\Billing\Entity\InvoiceItem;
use App\Billing\Event\InvoiceFinalized;
use App\Billing\Repository\InvoiceRepository; use App\Billing\Repository\InvoiceRepository;
use App\Billing\ValueObject\Money; use App\Billing\ValueObject\Money;
use App\ClinicService\Service\TariffService; use App\ClinicService\Service\TariffService;
@@ -12,6 +13,7 @@ use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Service\TenantInsuranceService; use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Entity\PatientSession; use App\Patient\Entity\PatientSession;
use App\Patient\Repository\PatientSessionRepository; use App\Patient\Repository\PatientSessionRepository;
use Psr\EventDispatcher\EventDispatcherInterface;
class InvoiceService class InvoiceService
{ {
@@ -22,6 +24,7 @@ class InvoiceService
private readonly BillingCalculator $calculator, private readonly BillingCalculator $calculator,
private readonly PatientSessionRepository $sessionRepo, private readonly PatientSessionRepository $sessionRepo,
private readonly InsuranceRepository $insuranceRepo, private readonly InsuranceRepository $insuranceRepo,
private readonly EventDispatcherInterface $events,
) {} ) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */ /** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
@@ -89,10 +92,13 @@ class InvoiceService
return $invoice; return $invoice;
} }
/** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */
public function finalize(Invoice $invoice): void public function finalize(Invoice $invoice): void
{ {
$invoice->finalize(); $invoice->finalize();
$this->invoiceRepo->save($invoice); $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\Entity\User;
use App\Auth\Repository\UserRepository; use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository; use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService; use App\Billing\Service\SessionBillingService;
use App\Billing\Service\InvoiceService;
use Psr\Log\LoggerInterface;
use App\Patient\Entity\PatientRecord; use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository; use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository; use App\Patient\Repository\PatientSessionRepository;
@@ -40,8 +38,7 @@ class PatientController extends BaseController
private readonly PatientRecordScopeResolver $scopeResolver, private readonly PatientRecordScopeResolver $scopeResolver,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo, private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly InvoiceService $invoiceService, private readonly SessionBillingService $sessionBilling,
private readonly ClaimService $claimService,
private readonly InvoiceRepository $invoiceRepo, private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo, private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
@@ -60,7 +57,6 @@ class PatientController extends BaseController
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo, private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess, private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly LoggerInterface $logger,
) {} ) {}
// ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ──────────────────────────── // ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ────────────────────────────
@@ -1058,34 +1054,11 @@ class PatientController extends BaseController
$data = json_decode($request->getContent(), true) ?? []; $data = json_decode($request->getContent(), true) ?? [];
$session = $this->patientService->createSession($record, $data, $entityType, $entityId); $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); 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'])] #[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse 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('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data)
|| array_key_exists('base_insurance_discount_percent', $data)) { || array_key_exists('base_insurance_discount_percent', $data)) {
$this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user); $this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user);
// بیمه ممکن است همین حالا به مراجعه اضافه شده باشد؛ بدون این، مراجعه‌ای که
// بیمه‌اش بعداً ثبت می‌شود هرگز صورتحساب و مطالبه نمی‌گیرد.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
} }
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع. // تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
+10 -1
View File
@@ -52,6 +52,7 @@ class PatientService
private readonly TenantInsuranceService $tenantInsuranceService, private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance, private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator, private readonly BillingCalculator $billingCalculator,
private readonly \App\Billing\Service\SessionBillingService $sessionBilling,
private readonly WalletService $walletService, private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo, private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Discount\Service\DiscountEngine $discountEngine,
@@ -238,9 +239,13 @@ class PatientService
$category = $this->appointmentInsurance->effectiveCategory($appointment); $category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category); $session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId()); $session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId());
$session->setBaseInsuranceDiscountPercent( $session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category) $this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
); );
$session->setSupplementaryDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category)
);
$shares = $this->calculateFinalPrice( $shares = $this->calculateFinalPrice(
$visitPrice, $visitPrice,
@@ -251,7 +256,7 @@ class PatientService
$entityType, $entityType,
$entityId, $entityId,
$appointment->getInsuranceBaseId(), $appointment->getInsuranceBaseId(),
null, $appointment->getInsuranceSupplementaryId(),
$category, $category,
); );
@@ -269,6 +274,10 @@ class PatientService
$session->addService($line); $session->addService($line);
} }
// مراجعهٔ بیمه‌دارِ آمده از نوبت هم باید صورتحساب و مطالبه بگیرد؛ بدون این،
// نوبت‌های قطعی‌شده هرگز به داشبورد مطالبات نمی‌رسیدند.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
return $session; 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);
}
}