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:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user