feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import InsuranceServiceCategoriesCard from './InsuranceServiceCategoriesCard';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
function mockCategories(enabled: Record<string, boolean>) {
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
service_categories: [
|
||||
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.outpatient },
|
||||
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabled.inpatient },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
put.mockReset();
|
||||
put.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('InsuranceServiceCategoriesCard', () => {
|
||||
it('سوییچ هر نوع خدمت را با وضعیت سرور نشان میدهد', async () => {
|
||||
mockCategories({ outpatient: true, inpatient: false });
|
||||
renderWithProviders(<InsuranceServiceCategoriesCard />);
|
||||
|
||||
expect(await screen.findByText('نوع خدمات بیمه')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('خدمات سرپایی')).toBeChecked();
|
||||
expect(screen.getByLabelText('خدمات بستری')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('تغییر سوییچ فقط همان نوع را PUT میکند', async () => {
|
||||
mockCategories({ outpatient: true, inpatient: true });
|
||||
renderWithProviders(<InsuranceServiceCategoriesCard />);
|
||||
await screen.findByText('نوع خدمات بیمه');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('خدمات بستری'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
|
||||
service_categories: [
|
||||
{ key: 'outpatient', enabled: true },
|
||||
{ key: 'inpatient', enabled: false },
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
it('غیرفعالکردن آخرین نوع فعال ارسال نمیشود', async () => {
|
||||
mockCategories({ outpatient: true, inpatient: false });
|
||||
renderWithProviders(<InsuranceServiceCategoriesCard />);
|
||||
await screen.findByText('نوع خدمات بیمه');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('خدمات سرپایی'));
|
||||
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('خدمات سرپایی')).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
interface ServiceCategoryRow {
|
||||
key: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* «نوع خدمات بیمه» — تنظیمی سراسری برای همهٔ بیمههای این پزشک/کلینیک: بیمهها کدام
|
||||
* نوع خدمات را پوشش میدهند. اگر فقط یک نوع فعال بماند، همان بهصورت خودکار مبنای
|
||||
* محاسبه است و سرِ پذیرش چیزی پرسیده نمیشود.
|
||||
*/
|
||||
export default function InsuranceServiceCategoriesCard() {
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('insurances', 'update');
|
||||
const [rows, setRows] = useState<ServiceCategoryRow[]>([]);
|
||||
|
||||
const { data } = useQuery<ApiResponse<{ service_categories?: ServiceCategoryRow[] }>>({
|
||||
queryKey: ['insurance-pricing'],
|
||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||
});
|
||||
|
||||
const serverRows = data?.data?.service_categories ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (serverRows.length > 0) setRows(serverRows);
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (next: ServiceCategoryRow[]) => api.put('/api/v1/insurance-pricing', {
|
||||
service_categories: next.map((r) => ({ key: r.key, enabled: r.enabled })),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوع خدمات بیمه ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message);
|
||||
setRows(serverRows);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = (key: string) => {
|
||||
const next = rows.map((r) => (r.key === key ? { ...r, enabled: !r.enabled } : r));
|
||||
if (next.every((r) => !r.enabled)) {
|
||||
toast.error('حداقل یک نوع خدمت باید فعال باشد');
|
||||
return;
|
||||
}
|
||||
setRows(next);
|
||||
save.mutate(next);
|
||||
};
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 6px' }}>نوع خدمات بیمه</h2>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.9, color: 'var(--text-2)' }}>
|
||||
بیمههای شما کدام نوع خدمات را پوشش میدهند؟ این تنظیم برای همهٔ بیمهها یکسان است.
|
||||
اگر فقط یک نوع فعال باشد، همان بهصورت پیشفرض برای محاسبهٔ بیمه استفاده میشود؛
|
||||
با فعال بودن هر دو، هنگام قطعیکردن نوبت نوع خدمت پرسیده میشود.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
|
||||
{rows.map((row) => (
|
||||
<label
|
||||
key={row.key}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={row.label}
|
||||
checked={row.enabled}
|
||||
disabled={!canUpdate || save.isPending}
|
||||
onChange={() => toggle(row.key)}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13 }}>{row.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,4 +101,31 @@ describe('InvoiceSummaryModal', () => {
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('فاکتور بیمهای: نوع خدمت، نام بیمه و سهمها درج میشوند', async () => {
|
||||
mockInvoice({
|
||||
...baseInvoice,
|
||||
base_insurance_rials: 1_785_600,
|
||||
patient_rials: 4_166_400,
|
||||
total_rials: 5_952_000,
|
||||
service_category: 'inpatient',
|
||||
service_category_label: 'خدمات بستری',
|
||||
base_insurance_name: 'بیمه ایران',
|
||||
items: [{ uuid: 'it1', title: 'ویزیت', quantity: 1, total_rials: 5_952_000, patient_rials: 4_166_400 }],
|
||||
});
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('اطلاعات بیمه')).toBeInTheDocument());
|
||||
expect(screen.getByText('نوع خدمت بیمه')).toBeInTheDocument();
|
||||
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
|
||||
mockInvoice(baseInvoice);
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
|
||||
expect(screen.queryByText('اطلاعات بیمه')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,11 @@ interface SessionData {
|
||||
interface Invoice {
|
||||
uuid: string; status: string; issued_at: number; total_rials: number;
|
||||
base_insurance_rials: number; supplementary_rials: number; patient_rials: number;
|
||||
/** نوع خدمتِ بیمهای که فاکتور با آن محاسبه شده + نام بیمهها. */
|
||||
service_category?: string | null;
|
||||
service_category_label?: string | null;
|
||||
base_insurance_name?: string | null;
|
||||
supplementary_insurance_name?: string | null;
|
||||
items: InvoiceItem[];
|
||||
session?: SessionData | null;
|
||||
}
|
||||
@@ -101,6 +106,18 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
||||
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}</span>,
|
||||
]]}
|
||||
/>
|
||||
{(inv.base_insurance_name || inv.service_category_label) && (
|
||||
<SectionTable
|
||||
title="اطلاعات بیمه"
|
||||
cols={['نوع خدمت بیمه', 'بیمه', 'سهم بیمه', 'سهم بیمار']}
|
||||
rows={[[
|
||||
inv.service_category_label ?? '—',
|
||||
inv.base_insurance_name ?? '—',
|
||||
formatRial(summary.baseInsurance + summary.suppInsurance),
|
||||
formatRial(summary.patientShare),
|
||||
]]}
|
||||
/>
|
||||
)}
|
||||
<SectionTable
|
||||
title="اطلاعات سرویس"
|
||||
cols={['سرویس', 'تعداد', 'مبلغ']}
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface SessionCardData {
|
||||
session_at?: number | null;
|
||||
insurance_base_id?: number | null;
|
||||
insurance_supplementary_id?: number | null;
|
||||
/** نوع خدمتِ بیمهایِ این مراجعه (سرپایی/بستری) و نام بیمهٔ پایه. */
|
||||
insurance_service_category?: string | null;
|
||||
insurance_service_category_label?: string | null;
|
||||
insurance_base_name?: string | null;
|
||||
base_insurance_discount_percent?: number;
|
||||
supplementary_discount_percent?: number;
|
||||
doctor_name?: string | null;
|
||||
@@ -154,6 +158,13 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, o
|
||||
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||||
{session.insurance_service_category_label && (
|
||||
<Row label="نوع خدمت بیمه" value={session.insurance_service_category_label} />
|
||||
)}
|
||||
{session.insurance_base_name && <Row label="بیمه" value={session.insurance_base_name} />}
|
||||
{(session.base_insurance_rials ?? 0) > 0 && (
|
||||
<Row label="سهم بیمه" value={formatRial(session.base_insurance_rials ?? 0)} valueStyle={{ color: '#16A34A', fontWeight: 500 }} />
|
||||
)}
|
||||
<Row label="هزینه" value={formatRial(session.final_price_rials ?? 0)} valueStyle={{ fontWeight: 500 }} />
|
||||
{!paid && <Row label="مانده بدهی" value={formatRial(debt)} valueStyle={{ color: '#EF4444', fontWeight: 500 }} />}
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,14 @@ describe('contractSummary', () => {
|
||||
const s = contractSummary(mk({ coverage_percent: 90, category_coverages: undefined }), categories);
|
||||
expect(s).toContain('پوشش ۹۰٪');
|
||||
});
|
||||
|
||||
/** تا برچسبهای فارسی از سرور نرسیدهاند، نباید کلید انگلیسی نشان داده شود. */
|
||||
it('بدون برچسبهای سرور، کلید انگلیسی نشان نمیدهد', () => {
|
||||
const s = contractSummary(mk({ coverage_percent: 90, category_coverages: { outpatient: 10, inpatient: 30 } }), []);
|
||||
expect(s).not.toContain('outpatient');
|
||||
expect(s).not.toContain('inpatient');
|
||||
expect(s).toContain('پوشش ۹۰٪');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TenantInsuranceContracts', () => {
|
||||
|
||||
@@ -39,9 +39,11 @@ export function filterInsurances(list: Contract[], query: string): Contract[] {
|
||||
*/
|
||||
export function contractSummary(c: Contract, categories: ServiceCategoryOption[] = []): string {
|
||||
const percents = c.category_coverages ?? {};
|
||||
const labelled = categories.length > 0
|
||||
? categories.filter((cat) => cat.key in percents).map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`)
|
||||
: Object.entries(percents).map(([key, percent]) => `${key} ${formatNumber(percent)}٪`);
|
||||
// برچسبها فقط از سرور میآیند؛ تا نیامدهاند خطِ قدیمیِ «پوشش X٪» نشان داده میشود
|
||||
// (کلید انگلیسیِ نوع خدمت هرگز به UI نمیرسد).
|
||||
const labelled = categories
|
||||
.filter((cat) => cat.key in percents)
|
||||
.map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`);
|
||||
|
||||
const parts = labelled.length > 0 ? labelled : [`پوشش ${formatNumber(c.coverage_percent)}٪`];
|
||||
if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) {
|
||||
@@ -310,6 +312,7 @@ function ContractCard({ contract: c, categories, open, onToggleRow, onEdit, onTo
|
||||
function ContractDetails({ contract: c, categories }: { contract: Contract; categories: ServiceCategoryOption[] }) {
|
||||
const percents = c.category_coverages ?? {};
|
||||
const isSupplementary = c.insurance_kind === 'supplementary';
|
||||
const perCategory = categories.filter((cat) => cat.key in percents);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -317,7 +320,10 @@ function ContractDetails({ contract: c, categories }: { contract: Contract; cate
|
||||
padding: 14,
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
|
||||
}}>
|
||||
{categories.filter((cat) => cat.key in percents).map((cat) => (
|
||||
{perCategory.length === 0 && (
|
||||
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
|
||||
)}
|
||||
{perCategory.map((cat) => (
|
||||
<DetailCell
|
||||
key={cat.key}
|
||||
label={`درصد پوشش — ${cat.label}`}
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('ConfirmAppointmentModal', () => {
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
|
||||
expect(screen.getByText('مجموع پرداختها از جمع کل بیشتر است.')).toBeInTheDocument();
|
||||
expect(screen.getByText('مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -115,3 +115,147 @@ describe('ConfirmAppointmentModal', () => {
|
||||
expect(amountInputs()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── بیمه: نوع خدمت + محاسبهٔ سهم ──────────────────────────────────────────────
|
||||
|
||||
const CONTRACT = {
|
||||
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
|
||||
is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
|
||||
category_coverages: { outpatient: 70, inpatient: 30 },
|
||||
};
|
||||
|
||||
/** ویزیت ۵٬۹۵۲٬۰۰۰ ریال، بدون خدمت — سناریوی مرجعِ سهم بیمه. */
|
||||
const referenceAppointment = {
|
||||
uuid: 'a1', version: 1, patient_name: 'محمد رضایی',
|
||||
visit_price_rials: 5_952_000, service_items: [],
|
||||
};
|
||||
|
||||
function mockInsurance(
|
||||
categories: { key: string; label: string; enabled: boolean }[],
|
||||
freeVisitPriceRials = 0,
|
||||
) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/insurance-pricing') {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
service_categories: categories,
|
||||
free_visit_price_rials: freeVisitPriceRials,
|
||||
default_service_category: categories.filter((c) => c.enabled).length === 1
|
||||
? categories.find((c) => c.enabled)!.key
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === '/api/v1/billing/tenant-insurances') {
|
||||
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
const BOTH = [
|
||||
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
|
||||
{ key: 'inpatient', label: 'خدمات بستری', enabled: true },
|
||||
];
|
||||
|
||||
function renderReference() {
|
||||
return renderWithProviders(
|
||||
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={referenceAppointment} onClose={() => {}} />,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* react-select با placeholder بهعنوان aria-label رندر میشود و منو با ArrowDown باز
|
||||
* میشود. انتخاب با role=option انجام میشود چون متنِ گزینه در live-region هم تکرار است.
|
||||
*/
|
||||
async function pick(selectLabel: string, optionText: string) {
|
||||
fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionText }));
|
||||
}
|
||||
|
||||
describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده میشود', async () => {
|
||||
mockInsurance(BOTH);
|
||||
renderReference();
|
||||
|
||||
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
|
||||
mockInsurance([
|
||||
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
|
||||
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
|
||||
]);
|
||||
renderReference();
|
||||
|
||||
expect(await screen.findByText('بیمه')).toBeInTheDocument();
|
||||
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
|
||||
mockInsurance(BOTH);
|
||||
renderReference();
|
||||
|
||||
await screen.findByText('نوع خدمت');
|
||||
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/سهم بیمه/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال میشوند (سرپایی ۷۰٪)', async () => {
|
||||
mockInsurance(BOTH);
|
||||
renderReference();
|
||||
|
||||
await screen.findByText('نوع خدمت');
|
||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||
await pick('بدون بیمه', 'بیمه ایران');
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
|
||||
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
|
||||
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,
|
||||
payments: [{ method: 'cash', amount_rials: 1_785_600 }],
|
||||
}));
|
||||
});
|
||||
|
||||
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
||||
mockInsurance(BOTH, 5_952_000);
|
||||
renderWithProviders(
|
||||
<ConfirmAppointmentModal
|
||||
open
|
||||
appointmentUuid="a1"
|
||||
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
|
||||
mockInsurance(BOTH, 9_000_000);
|
||||
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
|
||||
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||
});
|
||||
|
||||
it('نوع بستری درصد خودش را میگیرد (۳۰٪)', async () => {
|
||||
mockInsurance(BOTH);
|
||||
renderReference();
|
||||
|
||||
await screen.findByText('نوع خدمت');
|
||||
await pick('انتخاب نوع خدمت', 'خدمات بستری');
|
||||
await pick('بدون بیمه', 'بیمه ایران');
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
|
||||
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
|
||||
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
|
||||
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
||||
import Modal from '../ui/Modal';
|
||||
import PriceInput from '../ui/PriceInput';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
@@ -22,6 +24,8 @@ interface ServiceItem {
|
||||
uuid: string;
|
||||
name: string;
|
||||
price_rials?: number | null;
|
||||
service_category?: string | null;
|
||||
insurance_covered?: boolean;
|
||||
}
|
||||
|
||||
interface AppointmentLike {
|
||||
@@ -30,6 +34,8 @@ interface AppointmentLike {
|
||||
visit_price_rials?: number | null;
|
||||
service_items?: ServiceItem[] | null;
|
||||
patient_name?: string | null;
|
||||
insurance_service_category?: string | null;
|
||||
insurance_base_id?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -128,27 +134,55 @@ export default function ConfirmAppointmentModal({
|
||||
const appt: AppointmentLike | null = appointment
|
||||
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
|
||||
|
||||
const visitPrice = Number(appt?.visit_price_rials ?? 0);
|
||||
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
||||
const insurance = useAppointmentInsurance(open);
|
||||
|
||||
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را میگیرد؛
|
||||
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان میدهد و مبلغ ثبتشده فرق میکند.
|
||||
const visitPrice = insurance.visitPriceOf(appt?.visit_price_rials);
|
||||
const services = appt?.service_items ?? [];
|
||||
const servicesTotal = useMemo(
|
||||
() => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0),
|
||||
[services],
|
||||
);
|
||||
const total = visitPrice + servicesTotal;
|
||||
const [serviceCategory, setServiceCategory] = useState<string>('');
|
||||
const [insuranceId, setInsuranceId] = useState<string>('');
|
||||
|
||||
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیشفرضِ tenant.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
|
||||
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
|
||||
}, [open, appt?.uuid, insurance.defaultCategory]);
|
||||
|
||||
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
||||
|
||||
// آینهٔ سرور: ویزیت با نوع انتخابی، هر خدمت با نوع خودش.
|
||||
const shares = useMemo(() => insurance.breakdown([
|
||||
{ total: visitPrice, category: effectiveCategory, insured: true },
|
||||
...services.map(s => ({
|
||||
total: Number(s.price_rials ?? 0),
|
||||
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
||||
insured: s.insurance_covered !== false,
|
||||
})),
|
||||
], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
|
||||
|
||||
const payable = insuranceId ? shares.patient : total;
|
||||
|
||||
// ردیفِ اول تا لحظهای که کاربر مبلغ را دستی تغییر ندهد پیشفرضِ «پرداخت کامل» است؛
|
||||
// نوبت هنوز session ندارد، پس باقیماندهاش برابر کل هزینه است.
|
||||
// نوبت هنوز session ندارد، پس باقیماندهاش برابر مبلغِ قابل پرداخت است.
|
||||
useEffect(() => {
|
||||
if (!open || touched || total <= 0) return;
|
||||
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r)));
|
||||
}, [open, touched, total]);
|
||||
if (!open || touched || payable <= 0) return;
|
||||
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(payable) } : r)));
|
||||
}, [open, touched, payable]);
|
||||
|
||||
const paidRials = useMemo(
|
||||
() => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0),
|
||||
[rows],
|
||||
);
|
||||
const remaining = Math.max(0, total - paidRials);
|
||||
const overpaid = paidRials > total;
|
||||
const remaining = Math.max(0, payable - paidRials);
|
||||
const overpaid = paidRials > payable;
|
||||
|
||||
const paymentState = paidRials === 0
|
||||
? 'بدون پرداخت'
|
||||
@@ -160,6 +194,8 @@ export default function ConfirmAppointmentModal({
|
||||
mutationFn: () =>
|
||||
api.post<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
|
||||
version: appt?.version,
|
||||
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
|
||||
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
|
||||
payments: rows
|
||||
.filter(r => tomanToRial(r.amountToman) > 0)
|
||||
.map(r => ({
|
||||
@@ -254,6 +290,34 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{insurance.needsCategoryChoice && (
|
||||
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label>نوع خدمت</label>
|
||||
<SearchableSelect
|
||||
value={serviceCategory}
|
||||
onChange={(v) => setServiceCategory(v == null ? '' : String(v))}
|
||||
options={insurance.categoryOptions}
|
||||
placeholder="انتخاب نوع خدمت"
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label>بیمه</label>
|
||||
<SearchableSelect
|
||||
value={insuranceId}
|
||||
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
|
||||
options={insurance.insuranceOptions}
|
||||
placeholder="بدون بیمه"
|
||||
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
|
||||
isClearable
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* هزینهها */}
|
||||
<div
|
||||
style={{
|
||||
@@ -271,14 +335,24 @@ export default function ConfirmAppointmentModal({
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
|
||||
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<span>جمع کل</span>
|
||||
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
|
||||
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
|
||||
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -397,7 +471,7 @@ export default function ConfirmAppointmentModal({
|
||||
|
||||
{overpaid && (
|
||||
<p className="field-err" style={{ marginBottom: 14 }}>
|
||||
مجموع پرداختها از جمع کل بیشتر است.
|
||||
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -410,7 +484,7 @@ export default function ConfirmAppointmentModal({
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<span>پرداختشده</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, total))}</strong>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
|
||||
</div>
|
||||
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>باقیمانده</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('../../lib/api', () => ({
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import TurnsTimeline from './TurnsTimeline';
|
||||
import type { TimelineSlot } from './types';
|
||||
import type { Appointment } from '../../types';
|
||||
@@ -17,6 +18,7 @@ const appt = (over: Partial<Appointment> = {}): Appointment => ({
|
||||
slot_start: 1000, slot_end: 2000, appointment_date: '2024-12-31',
|
||||
appointment_time: '08:00', end_time: '08:35', status: 'completed',
|
||||
version: 1, created_at: '', service_item: { uuid: 's1', name: 'ویزیت عمومی' },
|
||||
...over,
|
||||
} as unknown as Appointment);
|
||||
|
||||
const occupiedSlot: TimelineSlot = {
|
||||
@@ -67,4 +69,33 @@ describe('TurnsTimeline', () => {
|
||||
expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument();
|
||||
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
|
||||
});
|
||||
|
||||
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان میدهد', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
|
||||
url === '/api/v1/billing/tenant-insurances'
|
||||
? Promise.resolve({ success: true, data: { data: [{
|
||||
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
|
||||
coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
|
||||
}] } })
|
||||
: Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
|
||||
const insured: TimelineSlot = {
|
||||
...occupiedSlot,
|
||||
appointment: appt({
|
||||
insurance_base_id: 3,
|
||||
insurance_service_category: 'inpatient',
|
||||
insurance_service_category_label: 'خدمات بستری',
|
||||
}),
|
||||
};
|
||||
|
||||
renderWithProviders(<TurnsTimeline slots={[insured]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('خدمات بستری · بیمه ایران')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('نوبتِ بدون بیمه چیپی نشان نمیدهد', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[occupiedSlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.queryByText(/بیمه ایران/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon, ShieldCheckIcon } from '@heroicons/react/24/outline';
|
||||
import type { Appointment } from '../../types';
|
||||
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
||||
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
||||
import AppointmentActionsMenu from '../AppointmentActions';
|
||||
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
|
||||
@@ -105,6 +106,13 @@ function OccupiedCard({
|
||||
}) {
|
||||
const cfg = turnStatusConfig(a.status);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// نام بیمه فقط با نگاشت از قراردادهای کششده به دست میآید؛ payload نوبت نامی ندارد
|
||||
// تا لیستهای نوبت به N+1 نیفتند.
|
||||
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
|
||||
const insuranceChip = [
|
||||
a.insurance_service_category_label,
|
||||
insurance.insuranceNameOf(a.insurance_base_id),
|
||||
].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<div
|
||||
onClick={() => onView(a)}
|
||||
@@ -133,6 +141,18 @@ function OccupiedCard({
|
||||
سرویس: {a.service_item?.name || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{insuranceChip !== '' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 'var(--r-pill)',
|
||||
background: 'var(--primary-soft)', color: 'var(--primary)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{insuranceChip}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
|
||||
@@ -145,6 +165,7 @@ function OccupiedCard({
|
||||
<ConfirmAppointmentModal
|
||||
open={confirmOpen}
|
||||
appointmentUuid={a.uuid}
|
||||
appointment={a}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
queryKey={queryKey}
|
||||
/>
|
||||
|
||||
@@ -112,3 +112,137 @@ describe('patientShareOf — آینهی BillingCalculator', () => {
|
||||
expect(share).toBe(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── بیمه: نوع خدمت در ثبت/ویرایش مراجعه ──────────────────────────────────────
|
||||
|
||||
const BASIC_CONTRACT = {
|
||||
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
|
||||
is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
|
||||
category_coverages: { outpatient: 70, inpatient: 30 },
|
||||
};
|
||||
|
||||
/** پروفایلِ بیمار با بیمهٔ پایه، تا بلوک بیمه نمایش داده شود. */
|
||||
const insuredProfile = { basic_insurance_id: 3 } as never;
|
||||
|
||||
function mockInsuranceEndpoints(enabled: string[]) {
|
||||
const categories = [
|
||||
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.includes('outpatient') },
|
||||
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabled.includes('inpatient') },
|
||||
];
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: {
|
||||
free_visit_price_rials: 5_952_000,
|
||||
require_visit_price: false,
|
||||
service_categories: categories,
|
||||
default_service_category: enabled.length === 1 ? enabled[0] : null,
|
||||
} });
|
||||
if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } });
|
||||
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [BASIC_CONTRACT] } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
/** react-select: منو با ArrowDown باز میشود و گزینه با role=option انتخاب میشود. */
|
||||
async function pick(selectLabel: string, optionText: string) {
|
||||
fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionText }));
|
||||
}
|
||||
|
||||
describe('CreateStep — نوع خدمت بیمه', () => {
|
||||
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده میشود', async () => {
|
||||
mockInsuranceEndpoints(['outpatient', 'inpatient']);
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
|
||||
mockInsuranceEndpoints(['outpatient']);
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است)
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200'));
|
||||
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('انتخاب نوع خدمت، درصد پوشش همان نوع را روی فرم میگذارد و ارسال میکند', async () => {
|
||||
mockInsuranceEndpoints(['outpatient', 'inpatient']);
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
await screen.findByText('نوع خدمت');
|
||||
|
||||
await pick('بدون بیمه پایه', 'بیمه ایران');
|
||||
await pick('انتخاب نوع خدمت', 'خدمات بستری');
|
||||
|
||||
// درصد بستری = ۳۰
|
||||
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30'));
|
||||
|
||||
fireEvent.click(screen.getByText('ایجاد سرویس'));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1]).toMatchObject({
|
||||
insurance_base_id: 3,
|
||||
insurance_service_category: 'inpatient',
|
||||
base_insurance_discount_percent: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateStep — نمایش و پیشانتخاب بیمه', () => {
|
||||
it('با داشتن قرارداد بیمه، بلوک بیمه نمایش داده میشود (بدون بیمهٔ پروفایل)', async () => {
|
||||
mockInsuranceEndpoints(['outpatient', 'inpatient']);
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بدون هیچ قراردادی، بلوک بیمه نمایش داده نمیشود', async () => {
|
||||
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
|
||||
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیشانتخاب میشود', async () => {
|
||||
mockInsuranceEndpoints(['outpatient']);
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
// بیمهٔ پروفایل (id=3) قرارداد فعال دارد → انتخاب و درصد سرپایی ۷۰
|
||||
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70'));
|
||||
});
|
||||
|
||||
it('ویرایش مراجعه: بیمهٔ ثبتشده مشخص و قابل تغییر است', async () => {
|
||||
mockInsuranceEndpoints(['outpatient', 'inpatient']);
|
||||
const editSession = {
|
||||
uuid: 's-9', visit_price_rials: 5_952_000, session_at: 1_700_000_000,
|
||||
insurance_base_id: 3, base_insurance_discount_percent: 30,
|
||||
insurance_service_category: 'inpatient',
|
||||
services: [], consumables: [],
|
||||
} as never;
|
||||
|
||||
renderWithProviders(
|
||||
<CreateStep recordUuid="r-1" profile={null} editSession={editSession} onCreated={vi.fn()} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
|
||||
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30');
|
||||
|
||||
// قابل تغییر: انتخاب نوع سرپایی درصد را به ۷۰ میبرد
|
||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,37 +11,23 @@ import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { digitsOnly } from '../../lib/utils';
|
||||
import {
|
||||
DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf,
|
||||
type CoverageRule as Rule, type TenantContract,
|
||||
} from '../../lib/insuranceShares';
|
||||
|
||||
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null; category_coverages?: Record<string, number> }
|
||||
interface Contract extends TenantContract { uuid: string }
|
||||
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
|
||||
interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null }
|
||||
interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string }
|
||||
interface PackageRow { uuid: string; title: string; total: number; available: boolean }
|
||||
interface StaffRow { uuid: string; full_name: string; active?: boolean }
|
||||
|
||||
/** ویزیت خدمتِ سرپایی است. */
|
||||
const VISIT_SERVICE_CATEGORY = 'outpatient';
|
||||
const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY;
|
||||
|
||||
/**
|
||||
* آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
|
||||
* فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است.
|
||||
*/
|
||||
export function patientShareOf(total: number, base: Rule | null, supp: Rule | 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;
|
||||
}
|
||||
let suppShare = 0;
|
||||
if (supp && supp.covered) {
|
||||
suppShare = Math.round(remaining * (supp.percent / 100));
|
||||
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
|
||||
remaining = remaining - suppShare;
|
||||
}
|
||||
return Math.min(remaining + (supp?.franchise ?? 0), total);
|
||||
}
|
||||
// آینهی BillingCalculator در lib/insuranceShares است؛ اینجا فقط re-export میشود تا
|
||||
// مصرفکنندگان قبلی (و تستها) نشکنند.
|
||||
export { patientShareOf };
|
||||
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
const nowHHMM = () => {
|
||||
@@ -90,6 +76,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const [suppId, setSuppId] = useState('');
|
||||
const [basePercent, setBasePercent] = useState('0');
|
||||
const [suppPercent, setSuppPercent] = useState('0');
|
||||
/** نوع خدمتِ بیمهایِ این مراجعه؛ خالی یعنی «پیشفرضِ tenant». */
|
||||
const [serviceCategory, setServiceCategory] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
// ── دادهها ──────────────────────────────────────────────────────────────
|
||||
@@ -124,6 +112,13 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
||||
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
|
||||
|
||||
// نوع خدماتِ بیمهایِ فعالِ این tenant — همان تنظیم سراسری «مدیریت بیمه».
|
||||
const enabledCategories: { key: string; label: string }[] =
|
||||
((pricingData as any)?.data?.service_categories ?? []).filter((c: any) => c.enabled);
|
||||
const needsCategoryChoice = enabledCategories.length > 1;
|
||||
const defaultCategory: string =
|
||||
(pricingData as any)?.data?.default_service_category ?? enabledCategories[0]?.key ?? VISIT_SERVICE_CATEGORY;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit)));
|
||||
}, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -141,6 +136,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
setNotes((editSession as any).notes ?? '');
|
||||
if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); }
|
||||
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
|
||||
setServiceCategory(editSession.insurance_service_category ?? '');
|
||||
setSelectedServices((editSession.services ?? []).map((s) => ({
|
||||
uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false,
|
||||
category: VISIT_SERVICE_CATEGORY,
|
||||
@@ -172,6 +168,21 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null;
|
||||
const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null;
|
||||
|
||||
/**
|
||||
* مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیشانتخاب میشود — فقط اگر برای همان بیمه
|
||||
* قرارداد فعال وجود داشته باشد. یکبار، و بعدش انتخاب کاربر دستنخورده میماند.
|
||||
*/
|
||||
const [insurancePrefilled, setInsurancePrefilled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isEdit || insurancePrefilled || contracts.length === 0) return;
|
||||
|
||||
const profileBase = profile?.basic_insurance_id ? String(profile.basic_insurance_id) : '';
|
||||
const profileSupp = profile?.supplementary_insurance_id ? String(profile.supplementary_insurance_id) : '';
|
||||
if (profileBase && baseOpts.some(o => o.value === profileBase)) applyBase(profileBase);
|
||||
if (profileSupp && suppOpts.some(o => o.value === profileSupp)) applySupp(profileSupp);
|
||||
setInsurancePrefilled(true);
|
||||
}, [contracts.length, profile, isEdit, insurancePrefilled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
|
||||
queryKey: ['service-coverage', baseContract?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`),
|
||||
@@ -185,10 +196,6 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
||||
const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
||||
|
||||
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
|
||||
const contractPercent = (contract: Contract, category: string): number =>
|
||||
Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
|
||||
|
||||
/**
|
||||
* قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
|
||||
* همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده میشود.
|
||||
@@ -200,21 +207,39 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const isSupplementary = contract.insurance_kind === 'supplementary';
|
||||
return {
|
||||
covered: true,
|
||||
percent: ov?.coverage_percent ?? contractPercent(contract, category),
|
||||
percent: ov?.coverage_percent ?? contractPercentFor(contract, category),
|
||||
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
|
||||
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
|
||||
};
|
||||
};
|
||||
|
||||
const coverageOf = (id: string): number => {
|
||||
/** نوع خدمتِ مؤثرِ ویزیت: انتخاب کاربر، وگرنه تنها نوع فعالِ tenant. */
|
||||
const visitCategory = serviceCategory || defaultCategory;
|
||||
|
||||
const coverageOf = (id: string, category = visitCategory): number => {
|
||||
const contract = contracts.find(c => String(c.insurance_id) === id);
|
||||
return contract ? contractPercent(contract, VISIT_SERVICE_CATEGORY) : 0;
|
||||
return contract ? contractPercentFor(contract, category) : 0;
|
||||
};
|
||||
const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); };
|
||||
const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); };
|
||||
|
||||
// نمایش شرطی بلوک بیمه: سرویسِ تحت پوشش بیمه انتخاب شده یا پروفایل بیمار بیمه دارد.
|
||||
const showInsurance = selectedServices.some(s => s.insured)
|
||||
/** تغییر نوع خدمت، درصدهای ویزیت را با همان نوع همگام میکند. */
|
||||
const applyServiceCategory = (category: string) => {
|
||||
setServiceCategory(category);
|
||||
if (baseId) setBasePercent(String(coverageOf(baseId, category || defaultCategory)));
|
||||
if (suppId) setSuppPercent(String(coverageOf(suppId, category || defaultCategory)));
|
||||
};
|
||||
|
||||
/**
|
||||
* بلوک بیمه هر وقت این پزشک/کلینیک قرارداد بیمهٔ فعال دارد نمایش داده میشود تا
|
||||
* بیمه قابل انتخاب و تغییر باشد؛ پیشتر تنها با سرویسِ تحتپوشش یا بیمهٔ پروفایل
|
||||
* ظاهر میشد و کاربر راهی برای انتخاب بیمه نداشت. مراجعهای که بیمه دارد هم
|
||||
* (حالت ویرایش) همیشه بلوک را نشان میدهد.
|
||||
*/
|
||||
const showInsurance = contracts.length > 0
|
||||
|| !!baseId
|
||||
|| !!suppId
|
||||
|| selectedServices.some(s => s.insured)
|
||||
|| !!profile?.basic_insurance_id
|
||||
|| !!profile?.supplementary_insurance_id;
|
||||
|
||||
@@ -257,9 +282,12 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const servicesPatient = useMemo(
|
||||
() => selectedServices.reduce((sum, x) => {
|
||||
const total = x.price * x.qty;
|
||||
if (!x.insured) return sum + total;
|
||||
// نوع خدمت از کاتالوگ خوانده میشود تا ردیفهای پیشپرشدهٔ ویرایش هم درست باشند.
|
||||
const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category;
|
||||
// نوع خدمت و پرچم پوشش از کاتالوگ خوانده میشوند تا ردیفهای پیشپرشدهٔ ویرایش
|
||||
// هم مثل سرور حساب شوند (هنگام prefill این دو را نداریم).
|
||||
const catalogItem = serviceItems.find(i => i.uuid === x.uuid);
|
||||
const insured = catalogItem?.insurance_covered ?? x.insured;
|
||||
if (!insured) return sum + total;
|
||||
const category = catalogItem?.service_category ?? x.category;
|
||||
return sum + patientShareOf(
|
||||
total,
|
||||
ruleFor(baseContract, baseCoverage, x.uuid, category),
|
||||
@@ -297,6 +325,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
supplementary_discount_percent: showInsurance ? supp : 0,
|
||||
...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}),
|
||||
...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}),
|
||||
// نوع خدمتِ ویزیت؛ سرور درصد پوشش را بر پایهٔ همین resolve میکند.
|
||||
insurance_service_category: showInsurance && (baseId || suppId) ? visitCategory : null,
|
||||
...(isEdit ? {} : { payment_method: 'pending' }),
|
||||
...(notes ? { notes } : {}),
|
||||
session_at: toSessionAt(dateISO, time),
|
||||
@@ -453,6 +483,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
{showInsurance && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>بیمه</span>
|
||||
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود؛ وگرنه همان نوعِ فعال. */}
|
||||
{needsCategoryChoice && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={fieldLabel}>نوع خدمت</span>
|
||||
<SearchableSelect
|
||||
inputId="service-category-select"
|
||||
options={enabledCategories.map(c => ({ value: c.key, label: c.label }))}
|
||||
value={serviceCategory || null}
|
||||
onChange={v => applyServiceCategory(v ? String(v) : '')}
|
||||
placeholder="انتخاب نوع خدمت"
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>بیمه پایه</span>
|
||||
|
||||
Reference in New Issue
Block a user