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
+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}>