feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage

- Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables.
- Reset old rial values to 0/NULL as they are not convertible to percentage.

feat(command): add SeedInsuranceScenarioCommand for seeding insurance data

- Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor.
- Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios.
This commit is contained in:
hamed
2026-07-29 13:28:59 +03:30
parent 11b4dcdd34
commit 4f4bce9fe2
31 changed files with 1497 additions and 137 deletions
+53 -12
View File
@@ -8,7 +8,7 @@ import type { ServiceCategoryOption } from '../hooks/useServiceCategories';
const mkContract = (over: Partial<Contract> = {}): Contract => ({
uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
version: 1, is_active: true, coverage_percent: 70, franchise_percent: 15,
annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
effective_to: null,
category_coverages: { outpatient: 70, inpatient: 30 },
@@ -27,11 +27,11 @@ const categories: ServiceCategoryOption[] = [
];
describe('buildInsurancePayload', () => {
it('converts toman → rials, per-category percents, and Y-m-d → unix', () => {
it('keeps the franchise a percent, converts ceiling toman → rials and Y-m-d → unix', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
categoryPercents: { outpatient: '80', inpatient: '30' },
franchise: '50000', ceiling: '2000000',
franchise: '10', ceiling: '2000000',
effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
});
expect(payload.insurance_id).toBe(3);
@@ -41,7 +41,7 @@ describe('buildInsurancePayload', () => {
{ key: 'outpatient', coverage_percent: 80 },
{ key: 'inpatient', coverage_percent: 30 },
]);
expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
expect(payload.franchise_percent).toBe(10); // درصد است، نه مبلغ
expect(payload.annual_ceiling_rials).toBe(20_000_000);
expect(typeof payload.effective_from).toBe('number');
expect(payload.effective_to).toBeGreaterThan(payload.effective_from!);
@@ -58,10 +58,10 @@ describe('buildInsurancePayload', () => {
it('forces franchise to zero on a basic contract', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '50000',
...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '10',
categoryPercents: { outpatient: '70' },
});
expect(payload.franchise_rials).toBe(0);
expect(payload.franchise_percent).toBe(0);
});
it('omits category_coverages when the user may not override them', () => {
@@ -75,9 +75,9 @@ describe('buildInsurancePayload', () => {
});
describe('contractToForm', () => {
it('maps rials → toman, contract kind, and effective category percents', () => {
const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' }));
expect(form.franchise).toBe('30000');
it('maps the franchise percent, contract kind, and effective category percents', () => {
const form = contractToForm(mkContract({ franchise_percent: 30, kind: 'supplementary' }));
expect(form.franchise).toBe('30');
expect(form.kind).toBe('supplementary');
expect(form.categoryPercents).toEqual({ outpatient: '70', inpatient: '30' });
});
@@ -95,7 +95,7 @@ describe('InsuranceModal', () => {
expect(screen.getByText('پایه')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات بستری')).toBeInTheDocument();
expect(screen.queryByText('فرانشیز (تومان)')).not.toBeInTheDocument();
expect(screen.queryByText('فرانشیز (درصد)')).not.toBeInTheDocument();
expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
});
@@ -105,7 +105,48 @@ describe('InsuranceModal', () => {
<InsuranceModal open editContract={null} options={options} categories={categories} kind="supplementary" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (درصد)')).toBeInTheDocument();
});
it('blocks submit until every rendered category has a valid percent', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal
open
editContract={mkContract({ category_coverages: { outpatient: 70 } })}
options={options}
categories={categories}
kind="basic"
onClose={() => {}}
onSubmit={onSubmit}
/>,
);
// بستری بدون درصد مانده: ثبت باید بسته باشد و پیام الزامی دیده شود.
expect(screen.getByText('ثبت بیمه')).toBeDisabled();
expect(screen.getByText('درصد پوشش الزامی است (۱ تا ۱۰۰)')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('درصد پوشش خدمات بستری'), { target: { value: '30' } });
expect(screen.getByText('ثبت بیمه')).not.toBeDisabled();
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalled();
});
it('renders only the service kinds it is given', () => {
renderWithProviders(
<InsuranceModal
open
editContract={null}
options={options}
categories={[{ key: 'outpatient', label: 'خدمات سرپایی' }]}
kind="basic"
onClose={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
expect(screen.queryByText('درصد پوشش — خدمات بستری')).not.toBeInTheDocument();
});
it('prefills the percents of an edited contract and marks admin defaults', () => {
@@ -132,7 +173,7 @@ describe('InsuranceModal', () => {
);
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
insurance_id: 3, coverage_percent: 70, franchise_rials: 0, kind: 'basic',
insurance_id: 3, coverage_percent: 70, franchise_percent: 0, kind: 'basic',
category_coverages: [
{ key: 'outpatient', coverage_percent: 70 },
{ key: 'inpatient', coverage_percent: 30 },
+39 -12
View File
@@ -22,7 +22,8 @@ export interface Contract {
version: number;
is_active: boolean;
coverage_percent: number;
franchise_rials: number;
/** درصد، نه مبلغ — سهم اجباری بیمار از مبلغ تحت پوشش تکمیلی. */
franchise_percent: number;
annual_ceiling_rials: number | null;
kind: string | null;
effective_from: number;
@@ -40,7 +41,7 @@ export interface InsuranceFormValues {
effectiveTo: string; // Y-m-d
/** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */
categoryPercents: Record<string, string>;
franchise: string; // toman
franchise: string; // percent
ceiling: string; // toman
}
@@ -64,7 +65,7 @@ export function contractToForm(c: Contract): InsuranceFormValues {
effectiveFrom: unixToIso(c.effective_from),
effectiveTo: unixToIso(c.effective_to),
categoryPercents: percentsToStrings(c.category_coverages),
franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
franchise: c.franchise_percent != null ? String(c.franchise_percent) : '',
ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
};
}
@@ -73,6 +74,12 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
}
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمی‌دهد. */
export function isValidPercent(raw?: string): boolean {
const n = Number(raw);
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
}
/**
* Build the API payload from form values (toman → rials, Y-m-d → unix).
* When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor
@@ -80,7 +87,7 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
*
* `category_coverages` is only sent when the user may override percentages — the
* backend rejects it otherwise, and omitting it keeps the contract on the central
* admin defaults. فرانشیز فقط در قرارداد تکمیلی معنا دارد.
* admin defaults. فرانشیز درصد است و فقط در قرارداد تکمیلی معنا دارد.
*/
export function buildInsurancePayload(
v: InsuranceFormValues,
@@ -95,7 +102,7 @@ export function buildInsurancePayload(
kind: v.kind || null,
// ستون قدیمی قرارداد؛ آخرین سطح fallback است و با درصد سرپایی همگام می‌ماند.
coverage_percent: Number(v.categoryPercents.outpatient ?? percents[0]?.[1] ?? 0) || 0,
franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo),
@@ -111,7 +118,7 @@ interface Props {
editContract: Contract | null;
/** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
options: InsuranceOption[];
/** انواع خدمت از سرور — یک ورودی درصد به ازای هر نوع رندر می‌شود. */
/** نوع خدمت‌های *فعالِ* همین tenant — یک ورودی درصد به ازای هر نوع رندر می‌شود. */
categories: ServiceCategoryOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string;
@@ -155,8 +162,15 @@ export default function InsuranceModal({
const setPercent = (key: string, raw: string) =>
setForm((f) => ({ ...f, categoryPercents: { ...f.categoryPercents, [key]: digitsOnly(raw, 3) } }));
// بدون مجوز update درصدها اصلاً ارسال نمی‌شوند، پس اجبارشان هم بی‌معناست.
const missingPercents = canUpdate
? categories.filter((c) => !isValidPercent(form.categoryPercents[c.key])).map((c) => c.key)
: [];
const franchiseInvalid = form.kind === 'supplementary' && form.franchise !== '' && Number(form.franchise) > 100;
const canSubmit = !!form.insuranceId && missingPercents.length === 0 && !franchiseInvalid;
const submit = () => {
if (!form.insuranceId) return;
if (!canSubmit) return;
onSubmit(buildInsurancePayload(form, doctorUuid, canUpdate));
};
@@ -177,7 +191,7 @@ export default function InsuranceModal({
<button
type="button"
className="btn primary"
disabled={!form.insuranceId || isPending}
disabled={!canSubmit || isPending}
onClick={submit}
>
{isPending ? '...' : 'ثبت بیمه'}
@@ -230,10 +244,12 @@ export default function InsuranceModal({
value={form.categoryPercents[c.key] ?? ''}
onChange={(e) => setPercent(c.key, e.target.value)}
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
<span style={{ fontSize: 11, color: missingPercents.includes(c.key) ? 'var(--danger)' : 'var(--text-3)' }}>
{!canUpdate
? 'مقدار پیش‌فرض تنظیمات مرکزی'
: sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
: missingPercents.includes(c.key)
? 'درصد پوشش الزامی است (۱ تا ۱۰۰)'
: sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
? 'پیش‌فرض ادمین'
: ' '}
</span>
@@ -244,8 +260,19 @@ export default function InsuranceModal({
<div style={{ display: 'grid', gridTemplateColumns: isBasic ? '1fr' : '1fr 1fr', gap: 12 }}>
{!isBasic && (
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
<label style={label}>فرانشیز (درصد)</label>
<input
type="text"
inputMode="numeric"
dir="ltr"
className="input"
aria-label="فرانشیز درصد"
value={form.franchise}
onChange={(e) => set({ franchise: digitsOnly(e.target.value, 3) })}
/>
<span style={{ fontSize: 11, color: franchiseInvalid ? 'var(--danger)' : 'var(--text-3)' }}>
{franchiseInvalid ? 'فرانشیز نمی‌تواند بیش از ۱۰۰ باشد' : 'سهم بیمار از مبلغ تحت پوشش'}
</span>
</div>
)}
<div style={field}>
@@ -19,14 +19,14 @@ interface CoverageRow {
service_item_uuid: string | null;
covered: boolean;
coverage_percent: number | null;
franchise_rials: number | null;
franchise_percent: number | null;
ceiling_rials: number | null;
}
interface Draft {
covered: boolean;
coverage_percent: number | null;
franchise_rials: number | null;
franchise_percent: number | null;
ceiling_rials: number | null;
}
@@ -46,20 +46,22 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
const rows = (data as any)?.data?.data as CoverageRow[] | undefined;
const existing = rows?.find((r) => r.service_item_uuid === item.uuid);
const [draft, setDraft] = useState<Draft>({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
// متن خام فیلد درصد جدا از مقدار عددی نگه داشته می‌شود تا کاربر بتواند فیلد را خالی کند.
const [draft, setDraft] = useState<Draft>({ covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
// متن خام فیلدهای درصد جدا از مقدار عددی نگه داشته می‌شود تا کاربر بتواند فیلد را خالی کند.
const [percentText, setPercentText] = useState('');
const [franchiseText, setFranchiseText] = useState('');
useEffect(() => {
setDraft(existing
? {
covered: existing.covered,
coverage_percent: existing.coverage_percent,
franchise_rials: existing.franchise_rials,
franchise_percent: existing.franchise_percent,
ceiling_rials: existing.ceiling_rials,
}
: { covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
: { covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
setPercentText(existing?.coverage_percent == null ? '' : String(existing.coverage_percent));
setFranchiseText(existing?.franchise_percent == null ? '' : String(existing.franchise_percent));
}, [existing]);
const saveMut = useMutation({
@@ -68,7 +70,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
service_item_uuid: item.uuid,
covered: draft.covered,
coverage_percent: draft.coverage_percent,
franchise_rials: draft.franchise_rials,
franchise_percent: draft.franchise_percent,
ceiling_rials: draft.ceiling_rials,
}),
onSuccess: () => {
@@ -144,14 +146,19 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">فرانشیز</label>
<PriceInput
className="input"
style={{ height: 40 }}
value={draft.franchise_rials ?? 0}
onChange={(v) => setDraft((d) => ({ ...d, franchise_rials: v || null }))}
placeholder="۰"
min={0}
<label className="field-label">فرانشیز (درصد)</label>
<input
type="text" inputMode="numeric" dir="ltr" className="input"
style={{ height: 40, textAlign: 'left' }}
aria-label="فرانشیز درصد خدمت"
value={franchiseText}
placeholder="بدون فرانشیز"
onChange={(e) => {
const digits = digitsOnly(e.target.value, 3);
setFranchiseText(digits);
setDraft((d) => ({ ...d, franchise_percent: parseUserNumberClamped(digits, 0, 100) }));
}}
onBlur={() => setFranchiseText(draft.franchise_percent == null ? '' : String(draft.franchise_percent))}
/>
</div>
<div style={{ minWidth: 0 }}>
@@ -17,7 +17,7 @@ const patch = api.patch as ReturnType<typeof vi.fn>;
const mk = (over: Partial<Contract>): Contract => ({
uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0,
version: 1, is_active: true, coverage_percent: 70, franchise_percent: 0,
annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over,
});
@@ -55,15 +55,15 @@ describe('contractSummary', () => {
});
it('shows the franchise only on a supplementary contract', () => {
const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories);
const basic = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'basic' }), categories);
expect(basic).not.toContain('فرانشیز');
const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories);
const supp = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'supplementary' }), categories);
expect(supp).toContain('فرانشیز');
});
it('marks an unlimited ceiling', () => {
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories);
const s = contractSummary(mk({ franchise_percent: 0, annual_ceiling_rials: null }), categories);
expect(s).toContain('سقف پوشش نامحدود');
});
@@ -46,8 +46,8 @@ export function contractSummary(c: Contract, categories: ServiceCategoryOption[]
.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) {
parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
if (c.insurance_kind === 'supplementary' && c.franchise_percent > 0) {
parts.push(`فرانشیز ${formatNumber(c.franchise_percent)}٪`);
}
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
@@ -112,6 +112,15 @@ export default function TenantInsuranceContracts() {
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? [];
const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? [];
// فرم قرارداد فقط نوع خدمت‌هایی را می‌گیرد که همین tenant بیمه‌ای‌شان می‌کند؛
// لیست سراسری service-categories اینجا اشتباه است چون نوع خاموش را هم می‌آورد.
const enabledCategories: ServiceCategoryOption[] = useMemo(
() => ((pricingQuery.data as any)?.data?.service_categories ?? [])
.filter((c: { enabled: boolean }) => c.enabled)
.map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })),
[pricingQuery.data],
);
const activeIds = new Set(contracts.map((c) => c.insurance_id));
// Add-mode options: only the active tab's kind, excluding already-contracted insurances.
const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id));
@@ -259,7 +268,7 @@ export default function TenantInsuranceContracts() {
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
categories={categories}
categories={enabledCategories}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
canUpdate={canUpdate}
@@ -338,7 +347,7 @@ function ContractDetails({ contract: c, categories }: { contract: Contract; cate
/>
))}
{isSupplementary && (
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
<DetailCell label="فرانشیز" value={c.franchise_percent > 0 ? `${formatNumber(c.franchise_percent)}٪` : '—'} />
)}
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
@@ -120,7 +120,7 @@ describe('ConfirmAppointmentModal', () => {
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,
is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
@@ -75,7 +75,7 @@ describe('TurnsTimeline', () => {
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,
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
}] } })
: Promise.resolve({ success: true, data: [] }),
);
@@ -89,17 +89,23 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => {
});
it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمی‌کند', () => {
const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50, ceiling: null }, null);
expect(share).toBe(0);
});
it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه می‌شود', () => {
it('فرانشیز درصدیِ تکمیلی از سهم بیمه کم می‌شود', () => {
// پایه ۷۰٪ → ۴۲۰٬۰۰۰؛ باقیمانده ۱۸۰٬۰۰۰؛ تکمیلی ۱۰۰٪ منهای فرانشیز ۱۰٪ → ۱۶۲٬۰۰۰.
const share = patientShareOf(
600_000,
{ covered: true, percent: 70, franchise: 90_000, ceiling: null },
{ covered: true, percent: 100, franchise: 50_000, ceiling: null },
{ covered: true, percent: 70, franchise: 50, ceiling: null },
{ covered: true, percent: 100, franchise: 10, ceiling: null },
);
expect(share).toBe(50_000);
expect(share).toBe(18_000);
});
it('فرانشیز بزرگ‌تر از تعهد، سهم بیمه را صفر می‌کند نه منفی', () => {
const share = patientShareOf(600_000, null, { covered: true, percent: 20, franchise: 80, ceiling: null });
expect(share).toBe(600_000);
});
it('سقف تعهد سهم بیمه را محدود می‌کند', () => {
@@ -117,7 +123,7 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => {
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,
is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
@@ -17,7 +17,7 @@ import {
} from '../../lib/insuranceShares';
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 CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_percent: number | null; ceiling_rials: 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 }
@@ -208,7 +208,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
return {
covered: true,
percent: ov?.coverage_percent ?? contractPercentFor(contract, category),
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
franchise: isSupplementary ? (ov?.franchise_percent ?? contract.franchise_percent) : 0,
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
};
};
+11 -8
View File
@@ -7,7 +7,7 @@
export interface CoverageRule {
covered: boolean;
percent: number;
/** فقط در بیمهٔ تکمیلی معنا دارد. */
/** درصد سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در بیمهٔ تکمیلی معنا دارد. */
franchise: number;
ceiling: number | null;
}
@@ -19,7 +19,7 @@ export interface TenantContract {
insurance_kind: string | null;
is_active?: boolean;
coverage_percent: number;
franchise_rials: number;
franchise_percent: number;
annual_ceiling_rials: number | null;
category_coverages?: Record<string, number>;
}
@@ -28,8 +28,9 @@ export interface TenantContract {
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
/**
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی (روی باقیمانده) + فرانشیزِ تکمیلی.
* فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی.
* فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر می‌شود،
* نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
*/
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
let baseShare = 0;
@@ -41,12 +42,14 @@ export function patientShareOf(total: number, base: CoverageRule | null, supp: C
}
let suppShare = 0;
if (supp && supp.covered) {
suppShare = Math.round(remaining * (supp.percent / 100));
suppShare = Math.max(
0,
Math.round(remaining * (supp.percent / 100)) - Math.round(remaining * (supp.franchise / 100)),
);
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
remaining = remaining - suppShare;
}
return Math.min(remaining + (supp?.franchise ?? 0), total);
return total - baseShare - suppShare;
}
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
@@ -61,7 +64,7 @@ export function ruleOf(contract: TenantContract | null, category: string): Cover
return {
covered: true,
percent: contractPercentFor(contract, category),
franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_rials : 0,
franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_percent : 0,
ceiling: contract.annual_ceiling_rials,
};
}
@@ -70,7 +70,7 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
const CONTRACT = {
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
+43 -3
View File
@@ -32,6 +32,15 @@ const PAYMENT_OPTIONS = [
{ value: 'unpaid', label: 'وصول‌نشده' },
];
/** واژگان مطالبه `base` است، نه `basic` که در قرارداد بیمه به کار می‌رود. */
const KIND_OPTIONS = [
{ value: '', label: 'همه انواع' },
{ value: 'base', label: 'پایه' },
{ value: 'supplementary', label: 'تکمیلی' },
];
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
const isoNDaysAgo = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() - days);
@@ -69,6 +78,7 @@ export default function ClaimsPage() {
const search = params.get('search') ?? '';
const status = params.get('status') ?? '';
const insuranceId = params.get('insurance_id') ?? '';
const kind = params.get('kind') ?? '';
const paymentStatus = params.get('payment_status') ?? '';
const from = params.get('from') ?? '';
const to = params.get('to') ?? '';
@@ -76,7 +86,7 @@ export default function ClaimsPage() {
const sort = params.get('sort') ?? 'last_activity_at';
const dir = (params.get('dir') ?? 'desc') as 'asc' | 'desc';
const hasFilters = !!(search || status || insuranceId || paymentStatus || from || to);
const hasFilters = !!(search || status || insuranceId || kind || paymentStatus || from || to);
/** تغییر فیلتر همیشه به صفحه‌ی اول برمی‌گردد؛ ماندن روی صفحه ۵ با نتیجه‌ی جدید بی‌معناست. */
const setParam = (patch: Record<string, string>) => {
@@ -95,13 +105,14 @@ export default function ClaimsPage() {
if (search) qs.set('search', search);
if (status) qs.set('status', status);
if (insuranceId) qs.set('insurance_id', insuranceId);
if (kind) qs.set('kind', kind);
if (paymentStatus) qs.set('payment_status', paymentStatus);
const fromUnix = toUnix(from);
const toUnixVal = endOfDayUnix(to);
if (fromUnix) qs.set('from', String(fromUnix));
if (toUnixVal) qs.set('to', String(toUnixVal));
return qs.toString();
}, [page, sort, dir, search, status, insuranceId, paymentStatus, from, to]);
}, [page, sort, dir, search, status, insuranceId, kind, paymentStatus, from, to]);
const listQuery = useQuery<PaginatedResponse<ClaimPatientRow>>({
queryKey: ['claims-by-patient', queryString],
@@ -142,6 +153,25 @@ export default function ClaimsPage() {
{ key: 'full_name', header: 'بیمار', sortable: true, render: (r) => r.full_name ?? '—' },
{ key: 'mobile', header: 'موبایل', render: (r) => r.mobile ?? '—' },
{ key: 'national_code', header: 'کد ملی', render: (r) => r.national_code ?? '—' },
{
key: 'insurances',
header: 'بیمه',
render: (r) => (
(r.insurances ?? []).length === 0 ? '—' : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{r.insurances.map((i) => (
<span
key={`${i.insurance_id}-${i.kind}`}
className={`badge ${i.kind === 'supplementary' ? 'violet' : 'blue'}`}
title={KIND_LABEL[i.kind ?? ''] ?? i.kind ?? ''}
>
{i.insurance_name ?? `بیمه #${i.insurance_id}`}
</span>
))}
</div>
)
),
},
{ key: 'claims_count', header: 'تعداد درخواست', sortable: true, render: (r) => formatNumber(r.claims_count) },
{ key: 'total_services_rials', header: 'مجموع خدمات', sortable: true, render: (r) => formatRial(r.total_services_rials) },
{ key: 'total_insurance_rials', header: 'سهم بیمه', sortable: true, render: (r) => formatRial(r.total_insurance_rials) },
@@ -176,6 +206,16 @@ export default function ClaimsPage() {
height={38}
/>
</div>
<div style={{ minWidth: 130 }}>
<label className="field-label">نوع بیمه</label>
<SearchableSelect
options={KIND_OPTIONS}
value={kind}
onChange={(v) => setParam({ kind: v ? String(v) : '' })}
placeholder="همه انواع"
height={38}
/>
</div>
<div style={{ minWidth: 150 }}>
<label className="field-label">وضعیت</label>
<SearchableSelect
@@ -225,7 +265,7 @@ export default function ClaimsPage() {
loading={listQuery.isLoading}
searchValue={search}
onSearchChange={(v) => setParam({ search: v })}
searchPlaceholder="نام، موبایل یا کد ملی بیمار"
searchPlaceholder="نام، موبایل، کد ملی بیمار یا نام بیمه"
emptyMessage="پرونده بیمه‌ای ثبت نشده است."
sortKey={sort}
sortDir={dir}
+2 -2
View File
@@ -43,7 +43,7 @@ interface CoverageRow {
service_item_uuid: string | null;
covered: boolean;
coverage_percent: number | null;
franchise_rials: number | null;
franchise_percent: number | null;
ceiling_rials: number | null;
}
@@ -278,7 +278,7 @@ function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance
) : (
<span style={{ fontSize: 12.5, color: 'var(--text-2)', display: 'inline-flex', gap: 10, whiteSpace: 'nowrap' }}>
<span>پوشش {formatNumber(percent)}٪</span>
{row?.franchise_rials ? <span>فرانشیز {formatRial(row.franchise_rials)}</span> : null}
{row?.franchise_percent ? <span>فرانشیز {formatNumber(row.franchise_percent)}٪</span> : null}
{row?.ceiling_rials ? <span>سقف {formatRial(row.ceiling_rials)}</span> : null}
{!row && <span className="muted">(ارث از قرارداد)</span>}
</span>
+8
View File
@@ -229,9 +229,17 @@ export interface ClaimPatientRow {
total_approved_rials: number;
total_paid_rials: number;
overall_status: ClaimStatus;
/** بیمه‌هایی که این بیمار زیرشان مطالبه دارد — واژگان مطالبه: base | supplementary. */
insurances: ClaimInsuranceRef[];
last_activity_at: number;
}
export interface ClaimInsuranceRef {
insurance_id: number;
insurance_name: string | null;
kind: string | null;
}
export interface ClaimStatusLogEntry {
uuid: string;
from_status: string | null;