feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
|
||||
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
|
||||
PhotoIcon, XMarkIcon, ArrowDownTrayIcon, ArrowUpTrayIcon, ExclamationTriangleIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -18,7 +19,10 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import InsuranceCoverageDefaultsModal from '../components/InsuranceCoverageDefaultsModal';
|
||||
import { useServiceCategories } from '../hooks/useServiceCategories';
|
||||
import { numericField } from '../lib/forms';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
|
||||
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
|
||||
|
||||
@@ -861,6 +865,7 @@ function InsurancesTab() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Insurance | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<number | null>(null);
|
||||
const [coverageTarget, setCoverageTarget] = useState<Insurance | null>(null);
|
||||
|
||||
const [idSort, setIdSort] = useState<'asc' | 'desc' | null>(null);
|
||||
const sortQs = idSort ? `&sort=id&order=${idSort}` : '';
|
||||
@@ -906,6 +911,10 @@ function InsurancesTab() {
|
||||
reset({ name: i.name, type: i.type, status: String(i.status) });
|
||||
};
|
||||
|
||||
// برچسب نوع خدمت از سرور میآید؛ افزودن نوع تازه نیازی به تغییر این صفحه ندارد.
|
||||
const { categories } = useServiceCategories();
|
||||
const categoryLabel = (key: string) => categories.find((c) => c.key === key)?.label ?? key;
|
||||
|
||||
const columns: Column<Insurance>[] = [
|
||||
{ key: 'id', sortable: true, header: 'شناسه', render: (i) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{i.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (i) => (
|
||||
@@ -921,6 +930,15 @@ function InsurancesTab() {
|
||||
</div>
|
||||
)},
|
||||
{ key: 'type', header: 'نوع', render: (i) => <span className={`badge ${i.type === 'basic' ? 'green' : 'blue'}`}><span className="bdot" />{i.type === 'basic' ? 'پایه' : 'تکمیلی'}</span> },
|
||||
{ key: 'coverage_defaults', header: 'درصد پوشش', render: (i) => {
|
||||
const entries = Object.entries(i.coverage_defaults ?? {});
|
||||
if (entries.length === 0) return <span className="muted">—</span>;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||
{entries.map(([key, percent]) => `${categoryLabel(key)} ${formatNumber(percent)}٪`).join(' · ')}
|
||||
</span>
|
||||
);
|
||||
}},
|
||||
{ key: 'status', header: 'وضعیت', render: (i) => <SBadge status={i.status} /> },
|
||||
];
|
||||
|
||||
@@ -930,11 +948,14 @@ function InsurancesTab() {
|
||||
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمهها..." emptyMessage="هیچ بیمهای یافت نشد"
|
||||
actions={(i) => (
|
||||
<>
|
||||
<button onClick={() => setCoverageTarget(i)} className="mini-btn" title="تنظیمات پوشش"><AdjustmentsHorizontalIcon style={{ width: 14, height: 14 }} /></button>
|
||||
<button onClick={() => openEdit(i)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
|
||||
<button onClick={() => setDeleteTarget(i)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<InsuranceCoverageDefaultsModal insurance={coverageTarget} onClose={() => setCoverageTarget(null)} />
|
||||
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
|
||||
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن بیمه'} size="sm" onClose={closeModal}
|
||||
|
||||
@@ -83,6 +83,7 @@ interface PricingInsurance {
|
||||
insurance_id: number;
|
||||
insurance_name: string;
|
||||
type: string;
|
||||
/** فقط «سهم بیمار ثابت» مدل قدیمی؛ ورودی هیچ محاسبهای نیست. */
|
||||
patient_share_rials: number | null;
|
||||
}
|
||||
interface InsurancePricing {
|
||||
@@ -90,6 +91,9 @@ interface InsurancePricing {
|
||||
insurances: PricingInsurance[];
|
||||
}
|
||||
|
||||
/** ویزیت خدمتِ سرپایی است، پس درصد پوشش همین نوع خدمت خوانده میشود. */
|
||||
const VISIT_SERVICE_CATEGORY = "outpatient";
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
cash: "نقدی",
|
||||
card: "کارت",
|
||||
@@ -350,6 +354,13 @@ function MyPatientsPageInner() {
|
||||
enabled: !!selectedRecord,
|
||||
});
|
||||
|
||||
// قراردادهای بیمهٔ همین tenant — منبع درصد پوشش (نه سهم بیمار ثابت).
|
||||
const { data: contractsData } = useQuery<ApiResponse<any>>({
|
||||
queryKey: ["tenant-insurances"],
|
||||
queryFn: () => api.get("/api/v1/billing/tenant-insurances"),
|
||||
enabled: !!selectedRecord,
|
||||
});
|
||||
|
||||
const { data: invoiceData } = useQuery<ApiResponse<any>>({
|
||||
queryKey: ["invoice", invoiceUuid],
|
||||
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
|
||||
@@ -393,21 +404,25 @@ function MyPatientsPageInner() {
|
||||
.filter((i) => i.type === "supplementary")
|
||||
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||||
|
||||
// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. share=null یعنی پوشش ندارد (۰٪).
|
||||
const shareToDiscountPercent = (insuranceId: string): number => {
|
||||
if (!insuranceId || freeVisitPrice <= 0) return 0;
|
||||
const ins = pricing?.insurances.find((i) => String(i.insurance_id) === insuranceId);
|
||||
if (!ins || ins.patient_share_rials == null) return 0;
|
||||
const covered = Math.max(0, freeVisitPrice - ins.patient_share_rials);
|
||||
return Math.round((covered / freeVisitPrice) * 1000) / 10;
|
||||
/**
|
||||
* درصد پوشش ویزیت از قرارداد فعال همان بیمه (سرپایی). قرارداد نبود → ۰٪.
|
||||
* `patient_share_rials` دیگر ورودی محاسبه نیست.
|
||||
*/
|
||||
const contractVisitPercent = (insuranceId: string): number => {
|
||||
if (!insuranceId) return 0;
|
||||
const contracts = (contractsData?.data as any)?.data ?? [];
|
||||
const contract = contracts.find(
|
||||
(c: any) => String(c.insurance_id) === insuranceId && c.is_active,
|
||||
);
|
||||
return Number(contract?.category_coverages?.[VISIT_SERVICE_CATEGORY] ?? contract?.coverage_percent ?? 0);
|
||||
};
|
||||
|
||||
const applyBaseInsurance = (insuranceId: string) => {
|
||||
setBaseInsuranceId(insuranceId);
|
||||
form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined);
|
||||
if (insuranceId && freeVisitPrice > 0) {
|
||||
form.setValue("visit_price_rials", freeVisitPrice);
|
||||
form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId));
|
||||
if (insuranceId) {
|
||||
if (freeVisitPrice > 0) form.setValue("visit_price_rials", freeVisitPrice);
|
||||
form.setValue("base_insurance_discount_percent", contractVisitPercent(insuranceId));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -415,7 +430,7 @@ function MyPatientsPageInner() {
|
||||
setSuppInsuranceId(insuranceId);
|
||||
form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined);
|
||||
if (insuranceId) {
|
||||
form.setValue("supplementary_discount_percent", shareToDiscountPercent(insuranceId));
|
||||
form.setValue("supplementary_discount_percent", contractVisitPercent(insuranceId));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user