+ {!isBasic && (
+
+
+ set({ franchise: digitsOnly(e.target.value) })} />
+
+ )}
set({ ceiling: digitsOnly(e.target.value) })} />
diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx
index 41d258bf..9055daaa 100644
--- a/assets/admin/components/ServiceItemFormModal.tsx
+++ b/assets/admin/components/ServiceItemFormModal.tsx
@@ -11,6 +11,7 @@ import type { ServiceItem, ClinicStaff } from '../types';
import type { InventoryPackage, InventoryItem } from '../hooks/useInventory';
import { rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
+import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
@@ -21,6 +22,8 @@ const itemSchema = z.object({
staff_uuids: z.array(z.string()).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
bookable: z.boolean().optional(),
+ /** نوع خدمت (سرپایی/بستری) — مبنای انتخاب درصد پوشش بیمه. */
+ service_category: z.string().min(1, 'نوع خدمت الزامی است'),
/** پکیج کالای مصرفی؛ رشتهی خالی یعنی بدون پکیج. */
inventory_package_uuid: z.string().optional(),
/** اقلام کالای تکی — مستقل از پکیج. */
@@ -28,9 +31,12 @@ const itemSchema = z.object({
});
type ItemForm = z.infer;
+const DEFAULT_SERVICE_CATEGORY = 'outpatient';
+
const EMPTY_FORM: ItemForm = {
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
- bookable: false, inventory_package_uuid: '', consumables: [],
+ bookable: false, service_category: DEFAULT_SERVICE_CATEGORY,
+ inventory_package_uuid: '', consumables: [],
};
interface Props {
@@ -75,6 +81,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
});
const inventoryItems = inventoryData?.data?.items ?? [];
+ const { categories } = useServiceCategories(item !== null);
+
const form = useForm({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
useEffect(() => {
@@ -86,6 +94,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
duration_minutes: editing.duration_minutes ?? undefined,
bookable: editing.bookable ?? false,
+ service_category: editing.service_category ?? DEFAULT_SERVICE_CATEGORY,
inventory_package_uuid: editing.inventory_package_uuid ?? '',
consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })),
}
@@ -242,6 +251,21 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
+
+
+ ({ value: c.key, label: c.label }))}
+ value={form.watch('service_category') || null}
+ onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
+ placeholder="انتخاب نوع خدمت"
+ noOptionsMessage="نوعی تعریف نشده است"
+ height={42}
+ />
+
+ درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
+
+
+
{
});
});
+const categories = [
+ { key: 'outpatient', label: 'سرپایی' },
+ { key: 'inpatient', label: 'بستری' },
+];
+
describe('contractSummary', () => {
- it('shows coverage, franchise and ceiling inline', () => {
- const s = contractSummary(mk({ coverage_percent: 90, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000 }));
- expect(s).toContain('پوشش');
- expect(s).toContain('فرانشیز');
+ it('breaks the coverage down per service category', () => {
+ const s = contractSummary(
+ mk({ category_coverages: { outpatient: 70, inpatient: 30 }, annual_ceiling_rials: 20_000_000 }),
+ categories,
+ );
+ expect(s).toContain('سرپایی ۷۰٪');
+ expect(s).toContain('بستری ۳۰٪');
expect(s).toContain('سقف پوشش');
});
- it('omits franchise when zero and marks unlimited ceiling', () => {
- const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }));
- expect(s).not.toContain('فرانشیز');
+
+ it('shows the franchise only on a supplementary contract', () => {
+ const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories);
+ expect(basic).not.toContain('فرانشیز');
+
+ const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories);
+ expect(supp).toContain('فرانشیز');
+ });
+
+ it('marks an unlimited ceiling', () => {
+ const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories);
expect(s).toContain('سقف پوشش نامحدود');
});
+
+ it('falls back to the legacy contract percent with no category rows', () => {
+ const s = contractSummary(mk({ coverage_percent: 90, category_coverages: undefined }), categories);
+ expect(s).toContain('پوشش ۹۰٪');
+ });
});
describe('TenantInsuranceContracts', () => {
diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx
index 34cbb3bb..b6fb4f82 100644
--- a/assets/admin/components/TenantInsuranceContracts.tsx
+++ b/assets/admin/components/TenantInsuranceContracts.tsx
@@ -7,9 +7,10 @@ import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
+import { useServiceCategories, type ServiceCategoryOption } from '../hooks/useServiceCategories';
import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
-import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
+import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, SOURCE_ADMIN_DEFAULT, buildInsurancePayload } from './InsuranceModal';
type Kind = 'basic' | 'supplementary';
@@ -32,11 +33,22 @@ export function filterInsurances(list: Contract[], query: string): Contract[] {
);
}
-/** One-line readable summary shown on the collapsed row: پوشش ۹۰٪ · فرانشیز … · سقف پوشش … */
-export function contractSummary(c: Contract): string {
- const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`];
- if (c.franchise_rials > 0) parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
+/**
+ * One-line readable summary shown on the collapsed row:
+ * سرپایی ۷۰٪ · بستری ۳۰٪ · سقف پوشش … — فرانشیز فقط در قراردادهای تکمیلی.
+ */
+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)}٪`);
+
+ 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)}`);
+ }
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
+
return parts.join(' · ');
}
@@ -89,6 +101,8 @@ export default function TenantInsuranceContracts() {
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
+ const { categories } = useServiceCategories();
+
const pricingQuery = useQuery({
queryKey: ['insurance-pricing', doctorUuid],
queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
@@ -227,6 +241,7 @@ export default function TenantInsuranceContracts() {
toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
@@ -242,8 +257,10 @@ export default function TenantInsuranceContracts() {
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
+ categories={categories}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
+ canUpdate={canUpdate}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
@@ -254,6 +271,7 @@ export default function TenantInsuranceContracts() {
interface RowProps {
contract: Contract;
+ categories: ServiceCategoryOption[];
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
@@ -262,7 +280,7 @@ interface RowProps {
canUpdate?: boolean;
}
-function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) {
+function ContractCard({ contract: c, categories, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) {
const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return (
@@ -277,27 +295,45 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
)}
- {contractSummary(c)}
+ {contractSummary(c, categories)}
{canUpdate && (
{})}>
)}
- {open &&
}
+ {open &&
}
);
}
/** Expanded full detail of a contract (all fields), shown when its card is open. */
-function ContractDetails({ contract: c }: { contract: Contract }) {
+function ContractDetails({ contract: c, categories }: { contract: Contract; categories: ServiceCategoryOption[] }) {
+ const percents = c.category_coverages ?? {};
+ const isSupplementary = c.insurance_kind === 'supplementary';
+
return (
-
-
0 ? formatRial(c.franchise_rials) : '—'} />
+ {categories.filter((cat) => cat.key in percents).map((cat) => (
+
+ {formatNumber(percents[cat.key])}٪
+ {c.category_coverage_source?.[cat.key] === SOURCE_ADMIN_DEFAULT && (
+ پیشفرض ادمین
+ )}
+
+ }
+ />
+ ))}
+ {isSupplementary && (
+ 0 ? formatRial(c.franchise_rials) : '—'} />
+ )}
diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx
index 014bbbda..b2366346 100644
--- a/assets/admin/components/session/CreateStep.test.tsx
+++ b/assets/admin/components/session/CreateStep.test.tsx
@@ -8,7 +8,7 @@ vi.mock('../../lib/api', () => ({
}));
import { api } from '../../lib/api';
-import CreateStep from './CreateStep';
+import CreateStep, { patientShareOf } from './CreateStep';
const get = api.get as ReturnType;
const post = api.post as ReturnType;
@@ -81,3 +81,34 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
});
});
+
+describe('patientShareOf — آینهی BillingCalculator', () => {
+ it('سناریوی مرجع: ۳۰٪ پوشش پایه روی ۵,۹۵۲,۰۰۰ ریال', () => {
+ const share = patientShareOf(5_952_000, { covered: true, percent: 30, franchise: 0, ceiling: null }, null);
+ expect(share).toBe(4_166_400);
+ });
+
+ it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمیکند', () => {
+ const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
+ expect(share).toBe(0);
+ });
+
+ it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه میشود', () => {
+ const share = patientShareOf(
+ 600_000,
+ { covered: true, percent: 70, franchise: 90_000, ceiling: null },
+ { covered: true, percent: 100, franchise: 50_000, ceiling: null },
+ );
+ expect(share).toBe(50_000);
+ });
+
+ it('سقف تعهد سهم بیمه را محدود میکند', () => {
+ const share = patientShareOf(600_000, { covered: true, percent: 70, franchise: 0, ceiling: 300_000 }, null);
+ expect(share).toBe(300_000);
+ });
+
+ it('notCovered → کل مبلغ سهم بیمار', () => {
+ const share = patientShareOf(600_000, { covered: false, percent: 0, franchise: 0, ceiling: null }, null);
+ expect(share).toBe(600_000);
+ });
+});
diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx
index c7c83988..da1e8d1a 100644
--- a/assets/admin/components/session/CreateStep.tsx
+++ b/assets/admin/components/session/CreateStep.tsx
@@ -12,15 +12,21 @@ import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesS
import { useAuthStore } from '../../stores/authStore';
import { digitsOnly } from '../../lib/utils';
-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 }
+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 }
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 }
-// آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
-function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
+/** ویزیت خدمتِ سرپایی است. */
+const VISIT_SERVICE_CATEGORY = 'outpatient';
+
+/**
+ * آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
+ * فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است.
+ */
+export function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
let baseShare = 0;
let remaining = total;
if (base && base.covered) {
@@ -34,8 +40,7 @@ function patientShareOf(total: number, base: Rule | null, supp: Rule | null): nu
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
remaining = remaining - suppShare;
}
- const franchise = (base?.franchise ?? 0) + (supp?.franchise ?? 0);
- return Math.min(remaining + franchise, total);
+ return Math.min(remaining + (supp?.franchise ?? 0), total);
}
const todayISO = () => new Date().toISOString().slice(0, 10);
@@ -75,7 +80,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [staffUuid, setStaffUuid] = useState('');
- const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]);
+ const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean; category: string }[]>([]);
const [consumableUuid, setConsumableUuid] = useState('');
const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]);
const [packageUuid, setPackageUuid] = useState('');
@@ -138,6 +143,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
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,
})).filter((s) => s.uuid));
setSelectedConsumables((editSession.consumables ?? []).map((c) => ({
uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1,
@@ -179,20 +185,31 @@ 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 ?? [];
- // قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه پیشفرض قرارداد.
- const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string): Rule | null => {
+ /** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
+ const contractPercent = (contract: Contract, category: string): number =>
+ Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
+
+ /**
+ * قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
+ * همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده میشود.
+ */
+ const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string, category: string): Rule | null => {
if (!contract) return null;
const ov = coverage.find(r => r.service_item_uuid === serviceUuid);
if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null };
+ const isSupplementary = contract.insurance_kind === 'supplementary';
return {
covered: true,
- percent: ov?.coverage_percent ?? contract.coverage_percent,
- franchise: ov?.franchise_rials ?? contract.franchise_rials,
- ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
+ percent: ov?.coverage_percent ?? contractPercent(contract, category),
+ franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
+ ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
};
};
- const coverageOf = (id: string): number => contracts.find(c => String(c.insurance_id) === id)?.coverage_percent ?? 0;
+ const coverageOf = (id: string): number => {
+ const contract = contracts.find(c => String(c.insurance_id) === id);
+ return contract ? contractPercent(contract, VISIT_SERVICE_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'); };
@@ -204,7 +221,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
// ── خدمات ────────────────────────────────────────────────────────────────
const addService = () => {
if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return;
- setSelectedServices(p => [...p, { uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1, insured: !!currentItem.insurance_covered }]);
+ setSelectedServices(p => [...p, {
+ uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1,
+ insured: !!currentItem.insurance_covered,
+ category: currentItem.service_category ?? VISIT_SERVICE_CATEGORY,
+ }]);
setItemUuid('');
};
const setServiceQty = (uuid: string, qty: number) => {
@@ -237,9 +258,15 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
() => selectedServices.reduce((sum, x) => {
const total = x.price * x.qty;
if (!x.insured) return sum + total;
- return sum + patientShareOf(total, ruleFor(baseContract, baseCoverage, x.uuid), ruleFor(suppContract, suppCoverage, x.uuid));
+ // نوع خدمت از کاتالوگ خوانده میشود تا ردیفهای پیشپرشدهٔ ویرایش هم درست باشند.
+ const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category;
+ return sum + patientShareOf(
+ total,
+ ruleFor(baseContract, baseCoverage, x.uuid, category),
+ ruleFor(suppContract, suppCoverage, x.uuid, category),
+ );
}, 0),
- [selectedServices, baseContract, suppContract, baseCoverage, suppCoverage], // eslint-disable-line react-hooks/exhaustive-deps
+ [selectedServices, baseContract, suppContract, baseCoverage, suppCoverage, serviceItems], // eslint-disable-line react-hooks/exhaustive-deps
);
const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]);
const afterBase = Math.round(visit * (1 - base / 100));
diff --git a/assets/admin/hooks/useServiceCategories.ts b/assets/admin/hooks/useServiceCategories.ts
new file mode 100644
index 00000000..7a2cdfb9
--- /dev/null
+++ b/assets/admin/hooks/useServiceCategories.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../lib/api';
+import type { ApiResponse } from '../lib/api';
+
+export interface ServiceCategoryOption {
+ key: string;
+ label: string;
+}
+
+/**
+ * لیست نوع خدمت (سرپایی/بستری/…) از سرور. هرگز hardcode نشود: افزودن نوع تازه
+ * فقط یک case در enum بکاند است و از همینجا در همهی فرمها ظاهر میشود.
+ */
+export function useServiceCategories(enabled = true) {
+ const { data, isLoading } = useQuery>({
+ queryKey: ['service-categories'],
+ queryFn: () => api.get('/api/v1/service-categories'),
+ staleTime: 60 * 60 * 1000,
+ enabled,
+ });
+
+ const rows = data?.data;
+
+ return { categories: Array.isArray(rows) ? rows : [], isLoading };
+}
diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx
index 5e60a2ff..72c9c8f6 100644
--- a/assets/admin/pages/CategoriesPage.tsx
+++ b/assets/admin/pages/CategoriesPage.tsx
@@ -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(null);
const [logoUrl, setLogoUrl] = useState(null);
const [uploadTarget, setUploadTarget] = useState(null);
+ const [coverageTarget, setCoverageTarget] = useState(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[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (i) => {i.id} },
{ key: 'name', header: 'نام', render: (i) => (
@@ -921,6 +930,15 @@ function InsurancesTab() {
)},
{ key: 'type', header: 'نوع', render: (i) =>
{i.type === 'basic' ? 'پایه' : 'تکمیلی'} },
+ { key: 'coverage_defaults', header: 'درصد پوشش', render: (i) => {
+ const entries = Object.entries(i.coverage_defaults ?? {});
+ if (entries.length === 0) return
—;
+ return (
+
+ {entries.map(([key, percent]) => `${categoryLabel(key)} ${formatNumber(percent)}٪`).join(' · ')}
+
+ );
+ }},
{ key: 'status', header: 'وضعیت', render: (i) =>
},
];
@@ -930,11 +948,14 @@ function InsurancesTab() {
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) => (
<>
+
>
)}
/>
+
+ setCoverageTarget(null)} />
{total > 20 && }
= {
cash: "نقدی",
card: "کارت",
@@ -350,6 +354,13 @@ function MyPatientsPageInner() {
enabled: !!selectedRecord,
});
+ // قراردادهای بیمهٔ همین tenant — منبع درصد پوشش (نه سهم بیمار ثابت).
+ const { data: contractsData } = useQuery>({
+ queryKey: ["tenant-insurances"],
+ queryFn: () => api.get("/api/v1/billing/tenant-insurances"),
+ enabled: !!selectedRecord,
+ });
+
const { data: invoiceData } = useQuery>({
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));
}
};
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts
index 27983bfe..f84b0681 100644
--- a/assets/admin/types/index.ts
+++ b/assets/admin/types/index.ts
@@ -415,6 +415,8 @@ export interface Insurance {
type: "basic" | "supplementary";
logo_url: string | null;
status: number;
+ /** درصد پوشش مرکزی به تفکیک نوع خدمت — `{ outpatient: 70, inpatient: 30 }`. */
+ coverage_defaults?: Record;
}
export interface Tag {
@@ -673,6 +675,9 @@ export interface ServiceItem {
staff_members?: { uuid: string; full_name: string }[];
active: boolean;
insurance_covered?: boolean;
+ /** نوع خدمت (سرپایی/بستری) — درصد پوشش بیمه بر همین اساس انتخاب میشود. */
+ service_category?: string;
+ service_category_label?: string;
duration_minutes?: number | null;
bookable?: boolean;
}
diff --git a/docs/api/billing.md b/docs/api/billing.md
index 29d56282..b513d2e0 100644
--- a/docs/api/billing.md
+++ b/docs/api/billing.md
@@ -10,9 +10,21 @@
- تعرفهی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
- قانون پوشش از قرارداد بیمهی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
-- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل → فرانشیز سهم بیمار.
+- درصد پوشش به تفکیک **نوع خدمت** (`ServiceItem.service_category`؛ ویزیت همیشه `outpatient`) و از زنجیرهٔ resolve توضیحدادهشده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته میشود.
+- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار.
-نمونه: کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
+**بیمهٔ پایه صرفاً درصدی است:**
+
+```
+سهم بیمهٔ پایه = round(کل × درصد پوشش پایه ÷ 100)
+سهم بیمار = کل − سهم بیمهٔ پایه
+```
+
+`franchise_rials` قرارداد پایه در محاسبه **بیاثر** است (ستون برای سازگاری و قراردادهای تکمیلی میماند).
+
+نمونهها:
+- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
+- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
---
diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md
index bc5557bb..0be760de 100644
--- a/docs/api/clinic-services.md
+++ b/docs/api/clinic-services.md
@@ -8,6 +8,27 @@
---
+## GET /api/v1/service-categories
+
+لیست انواع خدمت (سرپایی/بستری/…). **تنها منبع** این لیست برای کلاینتها؛ افزودن نوع تازه در
+بکاند یک `case` است و بدون تغییر فرانت اینجا ظاهر میشود. درصد پوشش بیمه به ازای همین
+نوعها تعیین میشود ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)).
+
+**Permission:** `IS_AUTHENTICATED_FULLY`
+
+**Response 200:**
+```json
+{
+ "success": true,
+ "data": [
+ { "key": "outpatient", "label": "خدمات سرپایی" },
+ { "key": "inpatient", "label": "خدمات بستری" }
+ ]
+}
+```
+
+---
+
## GET /api/v1/service-sections
لیست بخشهای سرویس entity جاری.
@@ -112,6 +133,8 @@
"price_rials": 500000,
"active": true,
"insurance_covered": false,
+ "service_category": "outpatient",
+ "service_category_label": "خدمات سرپایی",
"duration_minutes": 50,
"bookable": true,
"created_at": 1718000000,
@@ -220,6 +243,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
"price_rials": 500000,
"staff_uuid": "...",
"insurance_covered": true,
+ "service_category": "outpatient",
"duration_minutes": 50,
"bookable": true
}
@@ -233,12 +257,13 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر `staff_uuids` نباشد استفاده میشود) |
| insurance_covered | boolean | ❌ (پیشفرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمیفرستد؛ مقدارش بهصورت خودکار از ردیفهای پوشش بیمه همگام میشود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
+| service_category | string | ❌ (پیشفرض `outpatient`) — «نوع خدمت»؛ یکی از مقادیر [`GET /api/v1/service-categories`](#get-apiv1service-categories). درصد پوشش بیمهٔ این خدمت از همین نوع resolve میشود. مقدار نامعتبر → `422 ERR_VALIDATION_001` با فیلد `service_category` |
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
| bookable | boolean | ❌ (پیشفرض false) — «نمایش در نوبتدهی». فقط سرویسهای `bookable=true` در حالت نوبتدهی سرویسی قابلانتخاباند |
| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` |
| consumables | array\|null | ❌ — کالاهای **تکی** این خدمت: `[{ "item_uuid": "…", "amount": 2 }]`. **مکمل پکیج است، نه جایگزین آن** — یک خدمت میتواند همزمان پکیج و کالای تکی داشته باشد. ارسال این فیلد کل فهرست را **جایگزین** میکند (`[]` = حذف همه). هر کالا باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `consumables`. `amount` حداقل ۱ است |
-> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته میشود.
+> `bookable` و `service_category` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته میشوند؛ تغییر نوع خدمت در audit-log با برچسب «نوع خدمت» ثبت میشود.
**Response 201:** ServiceItem object (شامل `insurance_covered`)
diff --git a/docs/api/insurance.md b/docs/api/insurance.md
index ff9f68ec..aefa1860 100644
--- a/docs/api/insurance.md
+++ b/docs/api/insurance.md
@@ -10,6 +10,33 @@ Two resource types:
---
+## قاعدهٔ درصد پوشش (Coverage percent model)
+
+سهم بیمهٔ پایه فقط درصدی است:
+
+```
+سهم بیمهٔ پایه = round(مبلغ کل × درصد پوشش ÷ 100)
+سهم بیمار = مبلغ کل − سهم بیمهٔ پایه (فرانشیز در بیمهٔ پایه دخالت ندارد)
+```
+
+درصد پوشش به تفکیک **نوع خدمت** تعیین میشود. لیست انواع از `GET /api/v1/service-categories`
+میآید (فعلاً `outpatient` = خدمات سرپایی و `inpatient` = خدمات بستری) و هرگز در کلاینت
+hardcode نمیشود. ویزیت همیشه `outpatient` است.
+
+درصد مؤثر به این ترتیب resolve میشود (اولین مقدار موجود برنده است):
+
+| اولویت | منبع | جدول |
+|---|---|---|
+| ۱ | override همان خدمت | `tenant_service_coverage.coverage_percent` |
+| ۲ | override قرارداد برای نوع خدمت | `tenant_insurance_category_coverage` |
+| ۳ | پیشفرض مرکزی ادمین (اگر > ۰ باشد) | `insurance_coverage_defaults` |
+| ۴ | `coverage_percent` قرارداد (سازگاری با ردیفهای قدیمی) | `tenant_insurances` |
+
+**fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیشفرض ادمین
+خودبهخود بهروز میشود. `franchise_rials` فقط در قراردادهای `supplementary` اثر دارد.
+
+---
+
## GET `/api/v1/insurances`
List all active insurances.
@@ -31,19 +58,24 @@ List all active insurances.
"name": "بیمه تأمین اجتماعی",
"type": "basic",
"logo_url": "https://...",
- "status": "active"
+ "status": "active",
+ "coverage_defaults": { "outpatient": 70, "inpatient": 30 }
},
{
"id": 2,
"name": "بیمه ایران",
"type": "supplementary",
"logo_url": "https://...",
- "status": "active"
+ "status": "active",
+ "coverage_defaults": { "outpatient": 0, "inpatient": 0 }
}
]
}
```
+`coverage_defaults` درصدهای مرکزی ادمین به تفکیک نوع خدمت است؛ همیشه همهٔ نوعها حاضرند
+(نبودِ ردیف = `0`). پنل پزشک هنگام ساخت قرارداد همین مقادیر را پیشفرض بار میکند.
+
---
## GET `/api/v1/admin/insurances`
@@ -61,10 +93,21 @@ List all insurances with pagination (admin view — includes inactive).
| `type` | string | ❌ | `"basic"` or `"supplementary"` |
### Response `200`
+هر ردیف علاوه بر فیلدهای بیمه، `coverage_defaults` خود را هم دارد (یک کوئری برای کل صفحه، بدون N+1).
+
```json
{
"success": true,
- "data": [ ... ],
+ "data": [
+ {
+ "id": 1,
+ "name": "بیمه تأمین اجتماعی",
+ "type": "basic",
+ "logo_url": "https://...",
+ "status": "active",
+ "coverage_defaults": { "outpatient": 70, "inpatient": 30 }
+ }
+ ],
"meta": { "totalRecords": 15, "totalPages": 1, "currentPage": 1 }
}
```
@@ -77,6 +120,72 @@ List all insurances with pagination (admin view — includes inactive).
---
+## GET `/api/v1/admin/insurance/{id}/coverage-defaults`
+
+درصدهای پوشش مرکزی یک بیمه به تفکیک نوع خدمت. همیشه **همهٔ** نوعها برمیگردند
+(ردیف نداشته = `0`)، تا پنل ادمین جدول کامل نشان دهد.
+
+**Permission:** `ROLE_ADMIN`
+
+### Response `200`
+```json
+{
+ "success": true,
+ "data": {
+ "insurance_id": 3,
+ "categories": [
+ { "key": "outpatient", "label": "خدمات سرپایی", "coverage_percent": 70 },
+ { "key": "inpatient", "label": "خدمات بستری", "coverage_percent": 30 }
+ ]
+ }
+}
+```
+
+### Errors
+| Code | HTTP | Description |
+|------|------|-------------|
+| `ERR_AUTH_001` | 401 | Missing token |
+| `ERR_AUTH_006` | 403 | Not admin |
+| `ERR_VALIDATION_002` | 404 | بیمه یافت نشد |
+
+---
+
+## PUT `/api/v1/admin/insurance/{id}/coverage-defaults`
+
+ذخیرهٔ درصدهای مرکزی. تغییر این مقادیر بیدرنگ روی همهٔ قراردادهایی که برای همان نوع خدمت
+override ندارند اثر میگذارد.
+
+**Permission:** `ROLE_ADMIN`
+
+### Request Body (`application/json`)
+```json
+{
+ "categories": [
+ { "key": "outpatient", "coverage_percent": 70 },
+ { "key": "inpatient", "coverage_percent": 30 }
+ ]
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `categories` | array | ✅ | ردیفهایی که باید ذخیره شوند؛ ردیفهای نیامده دستنخورده میمانند |
+| `categories[].key` | string | ✅ | یکی از مقادیر `GET /api/v1/service-categories` |
+| `categories[].coverage_percent` | number | ✅ | ۰ تا ۱۰۰ |
+
+### Response `200`
+همان ساختار پاسخِ `GET` (وضعیت پس از ذخیره).
+
+### Errors
+| Code | HTTP | Description |
+|------|------|-------------|
+| `ERR_AUTH_001` | 401 | Missing token |
+| `ERR_AUTH_006` | 403 | Not admin |
+| `ERR_VALIDATION_002` | 404 | بیمه یافت نشد |
+| `ERR_VALIDATION_001` | 422 | `key` نامعتبر یا درصد خارج از بازهٔ ۰ تا ۱۰۰ |
+
+---
+
## POST `/api/v1/admin/insurance`
Create a new insurance.
@@ -307,20 +416,23 @@ entity جاری از `#[CurrentUser]` resolve میشود: نقش `ROLE_DOCTOR
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"type": "basic",
- "patient_share_rials": 1500000
+ "patient_share_rials": 1500000,
+ "coverage_defaults": { "outpatient": 70, "inpatient": 30 }
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
- "patient_share_rials": null
+ "patient_share_rials": null,
+ "coverage_defaults": { "outpatient": 0, "inpatient": 0 }
}
]
}
}
```
-- `patient_share_rials = null` یعنی این بیمه پذیرفته نمیشود (قیمتگذاری ندارد).
+- `coverage_defaults` — درصدهای مرکزی ادمین؛ پنل پزشک هنگام افزودن قرارداد از همین پر میکند.
+- `patient_share_rials = null` یعنی این بیمه پذیرفته نمیشود (قیمتگذاری ندارد). این مقدار **ورودی هیچ محاسبهای نیست**؛ محاسبهٔ سهم فقط از درصد پوشش انجام میشود.
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد میشوند.
### خطاها
@@ -399,13 +511,22 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
"annual_ceiling_rials": null,
"kind": "basic",
"effective_from": 1718900000,
- "effective_to": null
+ "effective_to": null,
+ "category_coverages": { "outpatient": 70, "inpatient": 30 },
+ "category_coverage_source": { "outpatient": "override", "inpatient": "admin_default" }
}
]
}
}
```
+| فیلد | توضیح |
+|------|-------|
+| `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve |
+| `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) |
+| `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است |
+| `franchise_rials` | فقط در قرارداد `supplementary` معنا دارد |
+
### POST `/api/v1/billing/tenant-insurances`
فعالسازی/بهروزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش میشود، وگرنه نسخهی جدید.
@@ -413,21 +534,35 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_id` | int | الزامی |
-| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
-| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
+| `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام میفرستد |
+| `franchise_rials` | int | فرانشیز — فقط در قرارداد `supplementary` اثر دارد |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بینهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیشفرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |
+| `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیشفرض مرکزی ادمین میماند (fallback زنده) |
+| `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` |
+| `category_coverages[].coverage_percent` | number \| null | ۰ تا ۱۰۰؛ `null` → override آن نوع حذف و به پیشفرض ادمین برمیگردد |
| `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را بهازای پزشک هدف ذخیره میکند (نگاه کنید به «تنظیمات per-doctor» بالا) |
-پاسخ `201`: `{ success, data: { …contract } }`.
-خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
+```json
+{
+ "insurance_id": 3,
+ "kind": "basic",
+ "category_coverages": [
+ { "key": "outpatient", "coverage_percent": 70 },
+ { "key": "inpatient", "coverage_percent": 30 }
+ ]
+}
+```
+
+پاسخ `201`: `{ success, data: { …contract, category_coverages, category_coverage_source } }`.
+خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی، یا `key` نامعتبر / درصد خارج از ۰–۱۰۰ · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد، یا ارسال `category_coverages` بدون مجوز `insurances.update`.
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال میشوند). فقط قرارداد متعلق به tenant جاری.
-**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `doctor_uuid` (اختیاری، برای هدفگیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
+**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدفگیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیینشدهٔ کاربر را دستنخورده نگه میدارد (برای reactivate).
- قرارداد باید به همان موجودیتِ resolveشده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.
diff --git a/docs/api/patient.md b/docs/api/patient.md
index 42c59918..16f5332d 100644
--- a/docs/api/patient.md
+++ b/docs/api/patient.md
@@ -504,10 +504,10 @@ Creates a new visit session for a patient record.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته میشود، وگرنه بیصدا نادیده گرفته میشود. روی قیمت اثری ندارد (فقط مرجع).
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیشفرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه میشود. آیتم ناموجود یا متعلق به tenant دیگر بیصدا رد میشود (همرفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیشفرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
-- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (`TenantInsurance.coveragePercent`) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
+- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد مؤثر قرارداد فعال (زنجیرهٔ resolve — [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)، با نوع خدمتِ `outpatient` برای ویزیت) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
- `final_price_rials` (سهم بیمار) به این صورت محاسبه میشود:
- - **ویزیت:** با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.)
- - **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
+ - **ویزیت:** خدمتِ سرپایی است و با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` حساب میشود — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. سهم بیمهٔ پایه = `round(کل × درصد ÷ 100)` و سهم بیمار = `کل − سهم پایه`؛ فرانشیزِ قرارداد پایه بیاثر است.
+ - **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود و درصد از **نوع خدمت** (`ServiceItem.service_category`: سرپایی/بستری) گرفته میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/سقف، و فرانشیز فقط در قرارداد تکمیلی؛ مقدار نبودِ override از قرارداد/پیشفرض مرکزی ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش میشوند.
- **گیت پوشش:** اگر `ServiceItem.insurance_covered` غیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهای `TenantServiceCoverage` سینک میشود ([insurance.md](insurance.md)).
- **سقف:** `annual_ceiling_rials` با وجود نامش بهصورت **سقف هر قلم** اعمال میشود؛ انباشت سالانهای در کد وجود ندارد.
diff --git a/migrations/Version20260725115603.php b/migrations/Version20260725115603.php
new file mode 100644
index 00000000..60d79540
--- /dev/null
+++ b/migrations/Version20260725115603.php
@@ -0,0 +1,39 @@
+addSql('CREATE TABLE insurance_coverage_defaults (id INT AUTO_INCREMENT NOT NULL, insurance_id INT NOT NULL, service_category VARCHAR(30) NOT NULL, coverage_percent NUMERIC(5, 2) NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_insurance_service_category (insurance_id, service_category), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
+
+ // Every insurance gets a full row set so the admin panel always shows a complete
+ // grid: a missing row must never be read as "0% on purpose".
+ foreach (['outpatient', 'inpatient'] as $category) {
+ $this->addSql(
+ 'INSERT INTO insurance_coverage_defaults (insurance_id, service_category, coverage_percent, updated_at)
+ SELECT i.id, :category, 0.00, UNIX_TIMESTAMP() FROM insurances i',
+ ['category' => $category],
+ );
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE insurance_coverage_defaults');
+ }
+}
diff --git a/migrations/Version20260725120831.php b/migrations/Version20260725120831.php
new file mode 100644
index 00000000..8d0218c1
--- /dev/null
+++ b/migrations/Version20260725120831.php
@@ -0,0 +1,30 @@
+addSql('CREATE TABLE tenant_insurance_category_coverage (id INT AUTO_INCREMENT NOT NULL, tenant_insurance_id INT NOT NULL, service_category VARCHAR(30) NOT NULL, coverage_percent NUMERIC(5, 2) NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_tenant_insurance_category (tenant_insurance_id, service_category), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE tenant_insurance_category_coverage');
+ }
+}
diff --git a/migrations/Version20260725121548.php b/migrations/Version20260725121548.php
new file mode 100644
index 00000000..724d7752
--- /dev/null
+++ b/migrations/Version20260725121548.php
@@ -0,0 +1,31 @@
+addSql("ALTER TABLE service_items ADD service_category VARCHAR(30) DEFAULT 'outpatient' NOT NULL");
+ $this->addSql("UPDATE service_items SET service_category = 'outpatient' WHERE service_category = ''");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE service_items DROP service_category');
+ }
+}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 97563a5d..7e7cafe3 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -87,7 +87,7 @@ parameters:
-
message: '#^Using nullsafe property access "\?\-\>franchiseRials" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
- count: 2
+ count: 1
path: src/Billing/Service/BillingCalculator.php
-
diff --git a/src/Billing/Service/BillingCalculator.php b/src/Billing/Service/BillingCalculator.php
index 666e0783..23a37fb1 100644
--- a/src/Billing/Service/BillingCalculator.php
+++ b/src/Billing/Service/BillingCalculator.php
@@ -10,7 +10,7 @@ class BillingCalculator
{
/**
* محاسبهی سهم برای یک آیتم.
- * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز سهم بیمار.
+ * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز تکمیلی.
*/
public function calculateItem(
Money $total,
@@ -37,11 +37,10 @@ class BillingCalculator
$remaining = $remaining->sub($suppShare);
}
- // فرانشیز سهم بیمار است؛ از سهم بیمه کم نمیکند ولی سهم بیمار از کل بیشتر نمیشود.
- $franchise = new Money(
- ($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0)
- );
- $patient = $remaining->add($franchise)->min($total);
+ // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ
+ // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمیکند.
+ $franchise = new Money($supplementary?->franchiseRials ?? 0);
+ $patient = $remaining->add($franchise)->min($total);
return new ShareBreakdown(
totalRials: $total->rials,
diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php
index 0c688d5b..d13c064b 100644
--- a/src/ClinicService/Controller/ClinicServiceController.php
+++ b/src/ClinicService/Controller/ClinicServiceController.php
@@ -7,6 +7,7 @@ use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemAuditLog;
use App\ClinicService\Entity\ServiceSection;
use App\Insurance\Entity\TenantServiceCoverage;
+use App\Insurance\Enum\ServiceCategory;
use App\ClinicService\Entity\Tariff;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
@@ -111,6 +112,30 @@ class ClinicServiceController extends BaseController
return null;
}
+ /** نوع خدمت (سرپایی/بستری) را ست میکند؛ مقدار نامعتبر ۴۲۲ میدهد. */
+ private function applyServiceCategory(ServiceItem $item, array $data): ?JsonResponse
+ {
+ if (!array_key_exists('service_category', $data)) {
+ return null;
+ }
+
+ $category = ServiceCategory::tryFromValue(
+ $data['service_category'] !== null ? (string) $data['service_category'] : null
+ );
+ if ($category === null) {
+ return $this->error(
+ ErrorCodes::ERR_VALIDATION_001,
+ 'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()),
+ 422,
+ 'service_category',
+ );
+ }
+
+ $item->setServiceCategory($category);
+
+ return null;
+ }
+
/**
* `consumables: [{item_uuid, amount}]` را روی خدمت مینشاند. آرایهی خالی یعنی حذف
* همهی اقلام. هر قلم باید متعلق به همان مطب/کلینیک باشد.
@@ -144,6 +169,23 @@ class ClinicServiceController extends BaseController
return null;
}
+ // ── Service categories (سرپایی/بستری) ────────────────────────────────────
+
+ /**
+ * تنها منبع لیست نوع خدمت برای فرانت (فرم خدمت و تنظیمات پوشش بیمه)؛
+ * افزودن نوع تازه فقط یک case در ServiceCategory است و اینجا خودکار ظاهر میشود.
+ */
+ #[Route('/api/v1/service-categories', methods: ['GET'])]
+ public function listServiceCategories(): JsonResponse
+ {
+ return $this->success([
+ 'data' => array_map(
+ static fn(ServiceCategory $c) => ['key' => $c->value, 'label' => $c->label()],
+ ServiceCategory::cases(),
+ ),
+ ]);
+ }
+
// ── Service Sections ─────────────────────────────────────────────────────
#[Route('/api/v1/service-sections', methods: ['GET'])]
@@ -327,6 +369,9 @@ class ClinicServiceController extends BaseController
if (array_key_exists('bookable', $data)) {
$item->setBookable((bool) $data['bookable']);
}
+ if (($categoryError = $this->applyServiceCategory($item, $data)) !== null) {
+ return $categoryError;
+ }
$packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId);
if ($packageError !== null) {
return $packageError;
@@ -379,6 +424,9 @@ class ClinicServiceController extends BaseController
if (array_key_exists('bookable', $data)) {
$item->setBookable((bool) $data['bookable']);
}
+ if (($categoryError = $this->applyServiceCategory($item, $data)) !== null) {
+ return $categoryError;
+ }
$packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId);
if ($packageError !== null) {
return $packageError;
diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php
index 9da00997..9b005e65 100644
--- a/src/ClinicService/Entity/ServiceItem.php
+++ b/src/ClinicService/Entity/ServiceItem.php
@@ -3,6 +3,7 @@
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceItemRepository;
+use App\Insurance\Enum\ServiceCategory;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -52,6 +53,10 @@ class ServiceItem
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
private bool $insuranceCovered = false;
+ /** نوع خدمت (سرپایی/بستری) — درصد پوشش بیمه به ازای همین نوع تعیین میشود. */
+ #[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class, options: ['default' => 'outpatient'])]
+ private ServiceCategory $serviceCategory = ServiceCategory::Outpatient;
+
/**
* @deprecated منبع حقیقتِ پوشش، TenantServiceCoverage است و هیچ محاسبهای این مقدار
* را نمیخواند. ستون برای دادهی تاریخی مانده ولی نه نوشته میشود و نه منتشر.
@@ -152,6 +157,7 @@ class ServiceItem
public function getPriceRials(): int { return $this->priceRials; }
public function isActive(): bool { return $this->active; }
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
+ public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
public function isBookable(): bool { return $this->bookable; }
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
@@ -190,6 +196,7 @@ class ServiceItem
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
+ public function setServiceCategory(ServiceCategory $v): self { $this->serviceCategory = $v; $this->updatedAt = time(); return $this; }
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
@@ -221,6 +228,8 @@ class ServiceItem
'price_rials' => $this->priceRials,
'active' => $this->active,
'insurance_covered' => $this->insuranceCovered,
+ 'service_category' => $this->getServiceCategory()->value,
+ 'service_category_label' => $this->getServiceCategory()->label(),
'duration_minutes' => $this->durationMinutes,
'bookable' => $this->bookable,
'inventory_package_id' => $this->inventoryPackageId,
diff --git a/src/ClinicService/Service/ServiceItemAuditService.php b/src/ClinicService/Service/ServiceItemAuditService.php
index f96088b3..ecdec7b9 100644
--- a/src/ClinicService/Service/ServiceItemAuditService.php
+++ b/src/ClinicService/Service/ServiceItemAuditService.php
@@ -21,6 +21,7 @@ class ServiceItemAuditService
'duration_minutes' => 'زمان متوسط',
'bookable' => 'نمایش در نوبتدهی',
'insurance_covered' => 'پوشش بیمه',
+ 'service_category' => 'نوع خدمت',
'inventory_package' => 'پکیج کالا',
'consumables' => 'کالاهای تکی',
];
@@ -37,6 +38,7 @@ class ServiceItemAuditService
'duration_minutes' => $item->getDurationMinutes() === null ? null : (string) $item->getDurationMinutes(),
'bookable' => $item->isBookable() ? '1' : '0',
'insurance_covered' => $item->isInsuranceCovered() ? '1' : '0',
+ 'service_category' => $item->getServiceCategory()->label(),
'inventory_package' => $item->getInventoryPackageId() === null ? null : (string) $item->getInventoryPackageId(),
'consumables' => $this->consumablesFingerprint($item),
];
diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php
index f0a8c647..68653448 100644
--- a/src/Insurance/Controller/InsuranceController.php
+++ b/src/Insurance/Controller/InsuranceController.php
@@ -16,6 +16,7 @@ use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
+use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Insurance\Service\TenantInsuranceService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
@@ -41,6 +42,7 @@ class InsuranceController extends BaseController
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly TenantServiceCoverageRepository $serviceCoverageRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
+ private readonly InsuranceCoverageDefaultService $coverageDefaults,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
@@ -103,8 +105,7 @@ class InsuranceController extends BaseController
$type = InsuranceType::tryFrom($typeParam);
}
- $items = array_map(fn(Insurance $i) => $i->toArray(), $this->insuranceRepo->findActive($type));
- return $this->success(['data' => $items]);
+ return $this->success(['data' => $this->withCoverageDefaults($this->insuranceRepo->findActive($type))]);
}
// ── Admin CRUD — Insurance ────────────────────────────────────────────────
@@ -195,10 +196,59 @@ class InsuranceController extends BaseController
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
- return $this->paginated(
- array_map(fn(Insurance $i) => $i->toArray(), $rows),
- (int) $total, $page, $limit
+ return $this->paginated($this->withCoverageDefaults($rows), (int) $total, $page, $limit);
+ }
+
+ /**
+ * Insurance rows carrying their central coverage percentages, resolved in one
+ * query for the whole page.
+ *
+ * @param Insurance[] $insurances
+ * @return list>
+ */
+ private function withCoverageDefaults(array $insurances): array
+ {
+ $defaults = $this->coverageDefaults->percentMapForMany(
+ array_map(static fn(Insurance $i) => (int) $i->getId(), $insurances)
);
+
+ return array_map(
+ static fn(Insurance $i) => $i->toArray() + ['coverage_defaults' => $defaults[$i->getId()] ?? []],
+ $insurances,
+ );
+ }
+
+ // ── Admin — central coverage percentages per service category ─────────────
+
+ #[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['GET'])]
+ #[IsGranted('ROLE_ADMIN')]
+ public function getCoverageDefaults(int $id): JsonResponse
+ {
+ if ($this->insuranceRepo->find($id) === null) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
+ }
+
+ return $this->success([
+ 'insurance_id' => $id,
+ 'categories' => $this->coverageDefaults->settingsRows($id),
+ ]);
+ }
+
+ #[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['PUT'])]
+ #[IsGranted('ROLE_ADMIN')]
+ public function saveCoverageDefaults(int $id, Request $request): JsonResponse
+ {
+ if ($this->insuranceRepo->find($id) === null) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
+ }
+
+ $data = json_decode($request->getContent(), true) ?? [];
+ $this->coverageDefaults->save($id, $data['categories'] ?? []);
+
+ return $this->success([
+ 'insurance_id' => $id,
+ 'categories' => $this->coverageDefaults->settingsRows($id),
+ ]);
}
// ── Upload logo ───────────────────────────────────────────────────────────
@@ -284,14 +334,20 @@ class InsuranceController extends BaseController
}
}
- $insurances = array_map(function (Insurance $i) use ($perInsurance) {
+ $catalog = $this->insuranceRepo->findActive(null);
+ $defaults = $this->coverageDefaults->percentMapForMany(
+ array_map(static fn(Insurance $i) => (int) $i->getId(), $catalog)
+ );
+
+ $insurances = array_map(function (Insurance $i) use ($perInsurance, $defaults) {
return [
'insurance_id' => $i->getId(),
'insurance_name' => $i->getName(),
'type' => $i->getType()->value,
'patient_share_rials' => $perInsurance[$i->getId()] ?? null,
+ 'coverage_defaults' => $defaults[$i->getId()] ?? [],
];
- }, $this->insuranceRepo->findActive(null));
+ }, $catalog);
return [
'entity_type' => $entityType,
@@ -394,12 +450,14 @@ class InsuranceController extends BaseController
$byId[$ins->getId()] = ['name' => $ins->getName(), 'type' => $ins->getType()->value];
}
- $data = array_map(function (TenantInsurance $c) use ($byId) {
+ $coverageView = $this->tenantInsuranceService->categoryCoverageViewForMany($contracts);
+
+ $data = array_map(function (TenantInsurance $c) use ($byId, $coverageView) {
$row = $c->toArray();
$row['insurance_name'] = $byId[$c->getInsuranceId()]['name'] ?? null;
// Contract-level kind wins over the catalog type when the tenant categorised it.
$row['insurance_kind'] = $c->getKind() ?? ($byId[$c->getInsuranceId()]['type'] ?? null);
- return $row;
+ return $row + $coverageView[$c->getId()];
}, $contracts);
return $this->success(['data' => $data]);
@@ -439,7 +497,37 @@ class InsuranceController extends BaseController
isset($data['kind']) && $data['kind'] !== '' ? (string) $data['kind'] : null,
);
- return $this->success(['data' => $contract->toArray()], 201);
+ if (($err = $this->applyCategoryCoverages($contract, $data, $user)) !== null) {
+ return $err;
+ }
+
+ return $this->success(['data' => $this->tenantInsuranceRow($contract)], 201);
+ }
+
+ /**
+ * Persists the optional per-category overrides of a contract. Sending nothing keeps
+ * the contract on the central admin defaults; overriding needs the update permission.
+ */
+ private function applyCategoryCoverages(TenantInsurance $contract, array $data, User $user): ?JsonResponse
+ {
+ if (!array_key_exists('category_coverages', $data)) {
+ return null;
+ }
+
+ if (!$this->secretaryAccess->canOrNonSecretary($user, 'insurances', 'update')
+ || !$this->clinicDoctorAccess->canOrNonMember($user, 'insurances', 'update')) {
+ return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازهی تغییر درصد پوشش را ندارید', 403);
+ }
+
+ $this->tenantInsuranceService->setCategoryCoverages($contract, $data['category_coverages'] ?? []);
+
+ return null;
+ }
+
+ /** @return array contract row carrying its effective category percentages */
+ private function tenantInsuranceRow(TenantInsurance $contract): array
+ {
+ return $contract->toArray() + $this->tenantInsuranceService->categoryCoverageView($contract);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['PATCH'])]
@@ -486,7 +574,11 @@ class InsuranceController extends BaseController
$this->tenantInsuranceRepo->save($contract);
- return $this->success(['data' => $contract->toArray()]);
+ if (($err = $this->applyCategoryCoverages($contract, $data, $user)) !== null) {
+ return $err;
+ }
+
+ return $this->success(['data' => $this->tenantInsuranceRow($contract)]);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])]
diff --git a/src/Insurance/Entity/InsuranceCoverageDefault.php b/src/Insurance/Entity/InsuranceCoverageDefault.php
new file mode 100644
index 00000000..6b4cd555
--- /dev/null
+++ b/src/Insurance/Entity/InsuranceCoverageDefault.php
@@ -0,0 +1,65 @@
+insuranceId = $insuranceId;
+ $this->serviceCategory = $serviceCategory;
+ $this->coveragePercent = (string) $coveragePercent;
+ $this->updatedAt = time();
+ }
+
+ public function getId(): ?int { return $this->id; }
+ public function getInsuranceId(): int { return $this->insuranceId; }
+ public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
+ public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
+
+ public function setCoveragePercent(float $v): self
+ {
+ $this->coveragePercent = (string) $v;
+ $this->updatedAt = time();
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'insurance_id' => $this->insuranceId,
+ 'service_category' => $this->serviceCategory->value,
+ 'label' => $this->serviceCategory->label(),
+ 'coverage_percent' => (float) $this->coveragePercent,
+ ];
+ }
+}
diff --git a/src/Insurance/Entity/TenantInsuranceCategoryCoverage.php b/src/Insurance/Entity/TenantInsuranceCategoryCoverage.php
new file mode 100644
index 00000000..c3ab9d15
--- /dev/null
+++ b/src/Insurance/Entity/TenantInsuranceCategoryCoverage.php
@@ -0,0 +1,64 @@
+tenantInsuranceId = $tenantInsuranceId;
+ $this->serviceCategory = $serviceCategory;
+ $this->coveragePercent = (string) $coveragePercent;
+ $this->updatedAt = time();
+ }
+
+ public function getId(): ?int { return $this->id; }
+ public function getTenantInsuranceId(): int { return $this->tenantInsuranceId; }
+ public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
+ public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
+
+ public function setCoveragePercent(float $v): self
+ {
+ $this->coveragePercent = (string) $v;
+ $this->updatedAt = time();
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'service_category' => $this->serviceCategory->value,
+ 'label' => $this->serviceCategory->label(),
+ 'coverage_percent' => (float) $this->coveragePercent,
+ ];
+ }
+}
diff --git a/src/Insurance/Enum/ServiceCategory.php b/src/Insurance/Enum/ServiceCategory.php
new file mode 100644
index 00000000..cddbdfdc
--- /dev/null
+++ b/src/Insurance/Enum/ServiceCategory.php
@@ -0,0 +1,33 @@
+ 'خدمات سرپایی',
+ self::Inpatient => 'خدمات بستری',
+ };
+ }
+
+ /** @return list */
+ public static function values(): array
+ {
+ return array_map(static fn(self $c) => $c->value, self::cases());
+ }
+
+ public static function tryFromValue(?string $value): ?self
+ {
+ return $value !== null ? self::tryFrom($value) : null;
+ }
+}
diff --git a/src/Insurance/Repository/InsuranceCoverageDefaultRepository.php b/src/Insurance/Repository/InsuranceCoverageDefaultRepository.php
new file mode 100644
index 00000000..3192520f
--- /dev/null
+++ b/src/Insurance/Repository/InsuranceCoverageDefaultRepository.php
@@ -0,0 +1,89 @@
+findBy(['insuranceId' => $insuranceId]);
+ }
+
+ public function findOneFor(int $insuranceId, ServiceCategory $category): ?InsuranceCoverageDefault
+ {
+ return $this->findOneBy(['insuranceId' => $insuranceId, 'serviceCategory' => $category]);
+ }
+
+ /** @return array service_category => percent */
+ public function percentMapFor(int $insuranceId): array
+ {
+ $map = [];
+ foreach ($this->findByInsurance($insuranceId) as $row) {
+ $map[$row->getServiceCategory()->value] = $row->getCoveragePercent();
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param list $insuranceIds
+ * @return array> insurance_id => (service_category => percent)
+ */
+ public function percentMapForMany(array $insuranceIds): array
+ {
+ if ($insuranceIds === []) {
+ return [];
+ }
+
+ $rows = $this->createQueryBuilder('d')
+ ->select('d.insuranceId AS insurance_id', 'd.serviceCategory AS service_category', 'd.coveragePercent AS coverage_percent')
+ ->where('d.insuranceId IN (:ids)')
+ ->setParameter('ids', $insuranceIds)
+ ->getQuery()
+ ->getArrayResult();
+
+ $map = [];
+ foreach ($rows as $row) {
+ $category = $row['service_category'] instanceof ServiceCategory
+ ? $row['service_category']->value
+ : (string) $row['service_category'];
+ $map[(int) $row['insurance_id']][$category] = (float) $row['coverage_percent'];
+ }
+
+ return $map;
+ }
+
+ public function save(InsuranceCoverageDefault $entity, bool $flush = true): void
+ {
+ $this->getEntityManager()->persist($entity);
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function flush(): void
+ {
+ $this->getEntityManager()->flush();
+ }
+
+ public function removeByInsurance(int $insuranceId): void
+ {
+ $this->createQueryBuilder('d')
+ ->delete()
+ ->where('d.insuranceId = :id')
+ ->setParameter('id', $insuranceId)
+ ->getQuery()
+ ->execute();
+ }
+}
diff --git a/src/Insurance/Repository/TenantInsuranceCategoryCoverageRepository.php b/src/Insurance/Repository/TenantInsuranceCategoryCoverageRepository.php
new file mode 100644
index 00000000..994f6004
--- /dev/null
+++ b/src/Insurance/Repository/TenantInsuranceCategoryCoverageRepository.php
@@ -0,0 +1,91 @@
+findOneBy(['tenantInsuranceId' => $tenantInsuranceId, 'serviceCategory' => $category]);
+ }
+
+ /** @return array service_category => percent, only overridden categories */
+ public function percentMapFor(int $tenantInsuranceId): array
+ {
+ $map = [];
+ foreach ($this->findBy(['tenantInsuranceId' => $tenantInsuranceId]) as $row) {
+ $map[$row->getServiceCategory()->value] = $row->getCoveragePercent();
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param list $tenantInsuranceIds
+ * @return array> tenant_insurance_id => (service_category => percent)
+ */
+ public function percentMapForMany(array $tenantInsuranceIds): array
+ {
+ if ($tenantInsuranceIds === []) {
+ return [];
+ }
+
+ $rows = $this->createQueryBuilder('c')
+ ->select('c.tenantInsuranceId AS tenant_insurance_id', 'c.serviceCategory AS service_category', 'c.coveragePercent AS coverage_percent')
+ ->where('c.tenantInsuranceId IN (:ids)')
+ ->setParameter('ids', $tenantInsuranceIds)
+ ->getQuery()
+ ->getArrayResult();
+
+ $map = [];
+ foreach ($rows as $row) {
+ $category = $row['service_category'] instanceof ServiceCategory
+ ? $row['service_category']->value
+ : (string) $row['service_category'];
+ $map[(int) $row['tenant_insurance_id']][$category] = (float) $row['coverage_percent'];
+ }
+
+ return $map;
+ }
+
+ public function save(TenantInsuranceCategoryCoverage $entity, bool $flush = true): void
+ {
+ $this->getEntityManager()->persist($entity);
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(TenantInsuranceCategoryCoverage $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function flush(): void
+ {
+ $this->getEntityManager()->flush();
+ }
+
+ public function removeByContract(int $tenantInsuranceId): void
+ {
+ $this->createQueryBuilder('c')
+ ->delete()
+ ->where('c.tenantInsuranceId = :id')
+ ->setParameter('id', $tenantInsuranceId)
+ ->getQuery()
+ ->execute();
+ }
+}
diff --git a/src/Insurance/Service/InsuranceCoverageDefaultService.php b/src/Insurance/Service/InsuranceCoverageDefaultService.php
new file mode 100644
index 00000000..4e8ad2c9
--- /dev/null
+++ b/src/Insurance/Service/InsuranceCoverageDefaultService.php
@@ -0,0 +1,104 @@
+ service_category => percent, all categories present */
+ public function percentMap(int $insuranceId): array
+ {
+ return $this->fill($this->repo->percentMapFor($insuranceId));
+ }
+
+ /**
+ * @param list $insuranceIds
+ * @return array>
+ */
+ public function percentMapForMany(array $insuranceIds): array
+ {
+ $stored = $this->repo->percentMapForMany($insuranceIds);
+
+ $map = [];
+ foreach ($insuranceIds as $id) {
+ $map[$id] = $this->fill($stored[$id] ?? []);
+ }
+
+ return $map;
+ }
+
+ public function percentFor(int $insuranceId, ServiceCategory $category): ?float
+ {
+ return $this->repo->findOneFor($insuranceId, $category)?->getCoveragePercent();
+ }
+
+ /**
+ * Category rows shaped for the admin settings UI.
+ *
+ * @return list
+ */
+ public function settingsRows(int $insuranceId): array
+ {
+ $map = $this->percentMap($insuranceId);
+
+ return array_map(static fn(ServiceCategory $c) => [
+ 'key' => $c->value,
+ 'label' => $c->label(),
+ 'coverage_percent' => $map[$c->value],
+ ], ServiceCategory::cases());
+ }
+
+ /**
+ * @param list $rows
+ * @throws AppException on an unknown category or an out-of-range percentage
+ */
+ public function save(int $insuranceId, array $rows): void
+ {
+ foreach ($rows as $row) {
+ $category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null);
+ if ($category === null) {
+ throw new AppException(
+ ErrorCodes::ERR_VALIDATION_001,
+ 'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()),
+ 422,
+ );
+ }
+
+ $percent = (float) ($row['coverage_percent'] ?? 0);
+ if ($percent < 0 || $percent > 100) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
+ }
+
+ $entity = $this->repo->findOneFor($insuranceId, $category)
+ ?? new InsuranceCoverageDefault($insuranceId, $category);
+
+ $this->repo->save($entity->setCoveragePercent($percent), false);
+ }
+
+ $this->repo->flush();
+ }
+
+ /** @param array $stored */
+ private function fill(array $stored): array
+ {
+ $map = [];
+ foreach (ServiceCategory::cases() as $category) {
+ $map[$category->value] = $stored[$category->value] ?? 0.0;
+ }
+
+ return $map;
+ }
+}
diff --git a/src/Insurance/Service/TenantInsuranceCleanupService.php b/src/Insurance/Service/TenantInsuranceCleanupService.php
index 73cc8b1e..3041bdc8 100644
--- a/src/Insurance/Service/TenantInsuranceCleanupService.php
+++ b/src/Insurance/Service/TenantInsuranceCleanupService.php
@@ -4,6 +4,7 @@ namespace App\Insurance\Service;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\TenantInsurance;
+use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
use App\Insurance\Entity\TenantServiceCoverage;
use Doctrine\ORM\EntityManagerInterface;
@@ -32,6 +33,11 @@ final class TenantInsuranceCleanupService
'DELETE ' . TenantServiceCoverage::class . ' c
WHERE c.tenantInsuranceId IN (:ids)'
)->setParameter('ids', $tenantInsuranceIds)->execute();
+
+ $this->em->createQuery(
+ 'DELETE ' . TenantInsuranceCategoryCoverage::class . ' k
+ WHERE k.tenantInsuranceId IN (:ids)'
+ )->setParameter('ids', $tenantInsuranceIds)->execute();
}
$this->em->createQuery(
diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php
index c465c10a..12399a33 100644
--- a/src/Insurance/Service/TenantInsuranceService.php
+++ b/src/Insurance/Service/TenantInsuranceService.php
@@ -4,7 +4,12 @@ namespace App\Insurance\Service;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Insurance\Entity\TenantInsurance;
+use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
+use App\Insurance\Entity\TenantServiceCoverage;
+use App\Insurance\Enum\InsuranceType;
+use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceRepository;
+use App\Insurance\Repository\TenantInsuranceCategoryCoverageRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\ValueObject\CoverageRule;
@@ -13,11 +18,18 @@ use App\Shared\Exception\AppException;
class TenantInsuranceService
{
+ public const SOURCE_SERVICE_OVERRIDE = 'service_override';
+ public const SOURCE_OVERRIDE = 'override';
+ public const SOURCE_ADMIN_DEFAULT = 'admin_default';
+ public const SOURCE_CONTRACT = 'contract';
+
public function __construct(
- private readonly TenantInsuranceRepository $repo,
- private readonly InsuranceRepository $insuranceRepo,
- private readonly TenantServiceCoverageRepository $coverageRepo,
- private readonly ServiceItemRepository $serviceItemRepo,
+ private readonly TenantInsuranceRepository $repo,
+ private readonly InsuranceRepository $insuranceRepo,
+ private readonly TenantServiceCoverageRepository $coverageRepo,
+ private readonly ServiceItemRepository $serviceItemRepo,
+ private readonly TenantInsuranceCategoryCoverageRepository $categoryCoverageRepo,
+ private readonly InsuranceCoverageDefaultService $coverageDefaults,
) {}
/**
@@ -69,6 +81,128 @@ class TenantInsuranceService
$this->repo->save($contract);
}
+ // ── Per-category coverage percentages ─────────────────────────────────────
+
+ /**
+ * Replaces the contract's category overrides. A row whose percentage is null is
+ * dropped, which hands that category back to the central admin default.
+ *
+ * @param list $rows
+ * @throws AppException on an unknown category or an out-of-range percentage
+ */
+ public function setCategoryCoverages(TenantInsurance $contract, array $rows): void
+ {
+ foreach ($rows as $row) {
+ $category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null);
+ if ($category === null) {
+ throw new AppException(
+ ErrorCodes::ERR_VALIDATION_001,
+ 'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()),
+ 422,
+ );
+ }
+
+ $existing = $this->categoryCoverageRepo->findOneFor($contract->getId(), $category);
+ $raw = $row['coverage_percent'] ?? null;
+
+ if ($raw === null || $raw === '') {
+ if ($existing !== null) {
+ $this->categoryCoverageRepo->remove($existing);
+ }
+ continue;
+ }
+
+ $percent = (float) $raw;
+ if ($percent < 0 || $percent > 100) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
+ }
+
+ $entity = $existing ?? new TenantInsuranceCategoryCoverage($contract->getId(), $category);
+ $this->categoryCoverageRepo->save($entity->setCoveragePercent($percent), false);
+ }
+
+ $this->categoryCoverageRepo->flush();
+ }
+
+ /**
+ * Effective percentage per category plus where each value came from, so the panel
+ * can tell an explicit override apart from an inherited central default.
+ *
+ * @return array{category_coverages: array, category_coverage_source: array}
+ */
+ public function categoryCoverageView(TenantInsurance $contract, ?array $overrides = null, ?array $defaults = null): array
+ {
+ $overrides ??= $this->categoryCoverageRepo->percentMapFor($contract->getId());
+ $defaults ??= $this->coverageDefaults->percentMap($contract->getInsuranceId());
+
+ $percents = [];
+ $sources = [];
+ foreach (ServiceCategory::cases() as $category) {
+ [$percent, $source] = $this->pickPercent($contract, $category, $overrides, $defaults);
+ $percents[$category->value] = $percent;
+ $sources[$category->value] = $source;
+ }
+
+ return ['category_coverages' => $percents, 'category_coverage_source' => $sources];
+ }
+
+ /**
+ * Same as categoryCoverageView() for a whole list, resolved in two queries.
+ *
+ * @param TenantInsurance[] $contracts
+ * @return array, category_coverage_source: array}>
+ */
+ public function categoryCoverageViewForMany(array $contracts): array
+ {
+ $overrides = $this->categoryCoverageRepo->percentMapForMany(
+ array_map(static fn(TenantInsurance $c) => (int) $c->getId(), $contracts)
+ );
+ $defaults = $this->coverageDefaults->percentMapForMany(
+ array_values(array_unique(array_map(static fn(TenantInsurance $c) => $c->getInsuranceId(), $contracts)))
+ );
+
+ $view = [];
+ foreach ($contracts as $contract) {
+ $view[(int) $contract->getId()] = $this->categoryCoverageView(
+ $contract,
+ $overrides[$contract->getId()] ?? [],
+ $defaults[$contract->getInsuranceId()] ?? [],
+ );
+ }
+
+ return $view;
+ }
+
+ /**
+ * درصد پوشش مؤثر یک نوع خدمت، به ترتیب اولویت:
+ * ۱) override قرارداد برای همان نوع خدمت (TenantInsuranceCategoryCoverage)
+ * ۲) پیشفرض مرکزی ادمین (InsuranceCoverageDefault) — تنها وقتی تعریف شده باشد
+ * ۳) coverage_percent قرارداد (سازگاری با ردیفهای قدیمی)
+ *
+ * @param array $overrides
+ * @param array $defaults
+ * @return array{0: float, 1: string}
+ */
+ private function pickPercent(
+ TenantInsurance $contract,
+ ServiceCategory $category,
+ array $overrides,
+ array $defaults,
+ ): array {
+ if (isset($overrides[$category->value])) {
+ return [$overrides[$category->value], self::SOURCE_OVERRIDE];
+ }
+
+ // A stored 0 means "not configured yet" (rows are pre-seeded for every
+ // insurance), so it must not shadow a legacy contract percentage.
+ $default = $defaults[$category->value] ?? 0.0;
+ if ($default > 0) {
+ return [$default, self::SOURCE_ADMIN_DEFAULT];
+ }
+
+ return [$contract->getCoveragePercent(), self::SOURCE_CONTRACT];
+ }
+
/**
* بررسی فعالبودن یک بیمه برای tenant. در پذیرش/صورتحساب استفاده میشود.
*/
@@ -82,7 +216,8 @@ class TenantInsuranceService
}
/**
- * قانون پوشش یک بیمه برای tenant جاری (برای BillingCalculator).
+ * قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator).
+ * ویزیت خدمتِ سرپایی است، پس درصد همان نوع خدمت resolve میشود.
* اگر قرارداد فعالی نباشد، notCovered برمیگردد.
*/
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
@@ -96,12 +231,7 @@ class TenantInsuranceService
return CoverageRule::notCovered();
}
- return new CoverageRule(
- coveragePercent: $contract->getCoveragePercent(),
- franchiseRials: $contract->getFranchiseRials(),
- ceilingRials: $contract->getAnnualCeilingRials(),
- covered: true,
- );
+ return $this->buildRule($contract, ServiceCategory::Outpatient, null);
}
/**
@@ -130,14 +260,66 @@ class TenantInsuranceService
return CoverageRule::notCovered();
}
+ return $this->buildRule($contract, $service->getServiceCategory(), $override);
+ }
+
+ /**
+ * ساخت قاعدهٔ پوشش با درصد resolveشده. فرانشیز فقط در قرارداد تکمیلی اثر دارد؛
+ * در بیمهٔ پایه قاعده صرفاً درصدی است (ستون DB برای سازگاری باقی میماند).
+ */
+ private function buildRule(
+ TenantInsurance $contract,
+ ServiceCategory $category,
+ ?TenantServiceCoverage $override,
+ ): CoverageRule {
+ $franchise = $this->isSupplementary($contract)
+ ? ($override?->getFranchiseRials() ?? $contract->getFranchiseRials())
+ : 0;
+
return new CoverageRule(
- coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
- franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(),
- ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
+ coveragePercent: $this->resolvePercent($contract, $category, $override),
+ franchiseRials: $franchise,
+ ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
}
+ /**
+ * درصد پوشش مؤثر، به ترتیب اولویت:
+ * ۱) override همان خدمت (TenantServiceCoverage.coverage_percent)
+ * ۲) override قرارداد برای نوع خدمت (TenantInsuranceCategoryCoverage)
+ * ۳) پیشفرض مرکزی ادمین (InsuranceCoverageDefault)
+ * ۴) coverage_percent قرارداد (سازگاری با ردیفهای قدیمی)
+ */
+ public function resolvePercent(
+ TenantInsurance $contract,
+ ServiceCategory $category,
+ ?TenantServiceCoverage $override = null,
+ ): float {
+ $servicePercent = $override?->getCoveragePercent();
+ if ($servicePercent !== null) {
+ return $servicePercent;
+ }
+
+ [$percent] = $this->pickPercent(
+ $contract,
+ $category,
+ $this->categoryCoverageRepo->percentMapFor($contract->getId()),
+ $this->coverageDefaults->percentMap($contract->getInsuranceId()),
+ );
+
+ return $percent;
+ }
+
+ /** قرارداد تکمیلی است؟ kind قرارداد بر نوع کاتالوگ اولویت دارد. */
+ private function isSupplementary(TenantInsurance $contract): bool
+ {
+ $kind = $contract->getKind()
+ ?? $this->insuranceRepo->find($contract->getInsuranceId())?->getType()->value;
+
+ return $kind === InsuranceType::Supplementary->value;
+ }
+
/** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
@@ -154,7 +336,7 @@ class TenantInsuranceService
?int $ceilingRials,
): void {
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
- ?? new \App\Insurance\Entity\TenantServiceCoverage($contract->getId(), $serviceItemId);
+ ?? new TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
diff --git a/src/Insurance/ValueObject/CoverageRule.php b/src/Insurance/ValueObject/CoverageRule.php
index 8408a85c..a3de1839 100644
--- a/src/Insurance/ValueObject/CoverageRule.php
+++ b/src/Insurance/ValueObject/CoverageRule.php
@@ -6,6 +6,7 @@ final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
+ /** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمیکند. */
public int $franchiseRials,
public ?int $ceilingRials,
public bool $covered = true,
diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php
index e0be2dc9..04256f1a 100644
--- a/src/Patient/Service/PatientService.php
+++ b/src/Patient/Service/PatientService.php
@@ -88,8 +88,9 @@ class PatientService
/**
* تفکیک سهم بیمهها و سهم بیمار برای یک مراجعه.
*
- * ویزیت با قاعدهی قرارداد بیمه (coverageRule) و هر خدمت با قاعدهی پوشش همان خدمت
- * (coverageRuleForService) محاسبه میشود — هر دو از طریق BillingCalculator، همان مسیری
+ * ویزیت خدمتِ سرپایی است و با قاعدهی قرارداد بیمه (coverageRule) محاسبه میشود؛ هر خدمت
+ * با قاعدهی پوشش نوعِ خودش (coverageRuleForService بر پایهی service_category همان خدمت)
+ * محاسبه میشود — هر دو از طریق BillingCalculator، همان مسیری
* که InvoiceService برای صدور فاکتور استفاده میکند. تنها منبع محاسبه همین است تا مبلغ
* صفحهی پرداخت و فاکتور نتوانند از هم واگرا شوند.
*
@@ -143,7 +144,10 @@ class PatientService
];
}
- /** درصد پوشش قرارداد فعال — snapshot روی مراجعه، نه ورودی محاسبه. */
+ /**
+ * درصد پوشش مؤثر ویزیت (سرپایی) از همان زنجیرهی resolve محاسبه —
+ * snapshot نمایشی روی مراجعه، نه ورودی محاسبه.
+ */
private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float
{
return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent;
diff --git a/tests/Billing/BillingCalculatorTest.php b/tests/Billing/BillingCalculatorTest.php
index 8b1352a2..8fc72aa7 100644
--- a/tests/Billing/BillingCalculatorTest.php
+++ b/tests/Billing/BillingCalculatorTest.php
@@ -59,15 +59,55 @@ class BillingCalculatorTest extends TestCase
$this->assertSame(300_000, $b->patientRials);
}
- public function testFranchiseAddedToPatient(): void
+ /** سناریوی مرجع کاربر: ویزیت 5,952,000 ریال با پوشش پایهٔ ۳۰٪ (بستری). */
+ public function testReferenceScenarioBasePercentOnly(): void
{
- // پایه 100% ولی فرانشیز 50,000 سهم بیمار
+ $base = new CoverageRule(coveragePercent: 30.0, franchiseRials: 0, ceilingRials: null);
+
+ $b = $this->calc->calculateItem(new Money(5_952_000), $base, null);
+
+ $this->assertSame(1_785_600, $b->baseInsuranceRials);
+ $this->assertSame(4_166_400, $b->patientRials);
+ $this->assertSame($b->totalRials, $b->baseInsuranceRials + $b->patientRials);
+ }
+
+ public function testBaseFranchiseDoesNotChargePatient(): void
+ {
+ // فرانشیز در بیمهٔ پایه بیاثر است: پایه 100% → سهم بیمار صفر.
$base = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
- $this->assertSame(50_000, $b->patientRials);
+ $this->assertSame(0, $b->patientRials);
$this->assertSame(600_000, $b->baseInsuranceRials);
}
+ public function testSupplementaryFranchiseAddedToPatient(): void
+ {
+ $base = new CoverageRule(coveragePercent: 70, franchiseRials: 90_000, ceilingRials: null);
+ $supp = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
+
+ $b = $this->calc->calculateItem(new Money(600_000), $base, $supp);
+
+ $this->assertSame(420_000, $b->baseInsuranceRials);
+ $this->assertSame(180_000, $b->supplementaryRials);
+ $this->assertSame(50_000, $b->patientRials);
+ }
+
+ public function testZeroPercentLeavesEverythingToPatient(): void
+ {
+ $base = new CoverageRule(coveragePercent: 0, franchiseRials: 0, ceilingRials: null);
+ $b = $this->calc->calculateItem(new Money(600_000), $base, null);
+ $this->assertSame(0, $b->baseInsuranceRials);
+ $this->assertSame(600_000, $b->patientRials);
+ }
+
+ public function testFullPercentLeavesNothingToPatient(): void
+ {
+ $base = new CoverageRule(coveragePercent: 100, franchiseRials: 0, ceilingRials: null);
+ $b = $this->calc->calculateItem(new Money(600_000), $base, null);
+ $this->assertSame(600_000, $b->baseInsuranceRials);
+ $this->assertSame(0, $b->patientRials);
+ }
+
public function testNotCoveredRule(): void
{
$b = $this->calc->calculateItem(new Money(600_000), CoverageRule::notCovered(), CoverageRule::notCovered());
diff --git a/tests/ClinicService/ServiceCategoryApiTest.php b/tests/ClinicService/ServiceCategoryApiTest.php
new file mode 100644
index 00000000..618114c2
--- /dev/null
+++ b/tests/ClinicService/ServiceCategoryApiTest.php
@@ -0,0 +1,90 @@
+createUser(['ROLE_DOCTOR']);
+ $doctor = new Doctor($owner, 'دکتر تست نوع خدمت');
+ $this->em->persist($doctor);
+ $this->em->flush();
+
+ $section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
+ $this->em->persist($section);
+ $this->em->flush();
+
+ return [$owner, $section];
+ }
+
+ public function testCategoryListIsServedByTheApi(): void
+ {
+ $owner = $this->createUser(['ROLE_DOCTOR']);
+
+ $body = $this->authJson('GET', '/api/v1/service-categories', $owner);
+
+ self::assertSame(200, $this->responseCode());
+ $keys = array_column($body['data']['data'], 'key');
+ self::assertContains('outpatient', $keys);
+ self::assertContains('inpatient', $keys);
+ }
+
+ public function testServiceDefaultsToOutpatient(): void
+ {
+ [$owner, $section] = $this->makeDoctorAndSection();
+
+ $created = $this->authJson('POST', '/api/v1/service-item', $owner, [
+ 'section_uuid' => $section->getUuid(),
+ 'name' => 'ویزیت سرپایی',
+ 'price_rials' => 1_000,
+ ]);
+
+ self::assertSame(201, $this->responseCode());
+ self::assertSame('outpatient', $created['data']['service_category']);
+ }
+
+ public function testCategoryIsStoredAndUpdatable(): void
+ {
+ [$owner, $section] = $this->makeDoctorAndSection();
+
+ $created = $this->authJson('POST', '/api/v1/service-item', $owner, [
+ 'section_uuid' => $section->getUuid(),
+ 'name' => 'جراحی',
+ 'price_rials' => 5_952_000,
+ 'service_category' => 'inpatient',
+ ]);
+ self::assertSame(201, $this->responseCode());
+ self::assertSame('inpatient', $created['data']['service_category']);
+ self::assertSame('خدمات بستری', $created['data']['service_category_label']);
+
+ $updated = $this->authJson('PATCH', '/api/v1/service-item/' . $created['data']['uuid'], $owner, [
+ 'service_category' => 'outpatient',
+ ]);
+ self::assertSame(200, $this->responseCode());
+ self::assertSame('outpatient', $updated['data']['service_category']);
+ }
+
+ public function testInvalidCategoryIsRejected(): void
+ {
+ [$owner, $section] = $this->makeDoctorAndSection();
+
+ $this->authJson('POST', '/api/v1/service-item', $owner, [
+ 'section_uuid' => $section->getUuid(),
+ 'name' => 'خدمت نامعتبر',
+ 'service_category' => 'dental',
+ ]);
+
+ self::assertSame(422, $this->responseCode());
+ }
+}
diff --git a/tests/Insurance/CoverageDefaultsApiTest.php b/tests/Insurance/CoverageDefaultsApiTest.php
new file mode 100644
index 00000000..e194b138
--- /dev/null
+++ b/tests/Insurance/CoverageDefaultsApiTest.php
@@ -0,0 +1,100 @@
+em->persist($insurance);
+ $this->em->flush();
+
+ return $insurance;
+ }
+
+ public function testAdminGetsEveryServiceCategory(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $insurance = $this->makeInsurance();
+
+ $body = $this->authJson('GET', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $admin);
+
+ $this->assertSame(200, $this->responseCode());
+ $keys = array_column($body['data']['categories'], 'key');
+ $this->assertContains('outpatient', $keys);
+ $this->assertContains('inpatient', $keys);
+ }
+
+ public function testAdminSavesPercentages(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $insurance = $this->makeInsurance();
+
+ $body = $this->authJson('PUT', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $admin, [
+ 'categories' => [
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ],
+ ]);
+
+ $this->assertSame(200, $this->responseCode());
+ $percents = array_column($body['data']['categories'], 'coverage_percent', 'key');
+ $this->assertEquals(70.0, $percents['outpatient']);
+ $this->assertEquals(30.0, $percents['inpatient']);
+ }
+
+ public function testPercentAbove100IsRejected(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $insurance = $this->makeInsurance();
+
+ $this->authJson('PUT', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $admin, [
+ 'categories' => [['key' => 'outpatient', 'coverage_percent' => 101]],
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ }
+
+ public function testUnknownCategoryIsRejected(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $insurance = $this->makeInsurance();
+
+ $this->authJson('PUT', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $admin, [
+ 'categories' => [['key' => 'dental', 'coverage_percent' => 50]],
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ }
+
+ public function testNonAdminIsForbidden(): void
+ {
+ $doctor = $this->createUser(['ROLE_DOCTOR']);
+ $insurance = $this->makeInsurance();
+
+ $this->authJson('GET', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $doctor);
+ $this->assertSame(403, $this->responseCode());
+
+ $this->authJson('PUT', '/api/v1/admin/insurance/' . $insurance->getId() . '/coverage-defaults', $doctor, [
+ 'categories' => [['key' => 'outpatient', 'coverage_percent' => 50]],
+ ]);
+ $this->assertSame(403, $this->responseCode());
+ }
+
+ public function testMissingInsuranceIsNotFound(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $this->authJson('GET', '/api/v1/admin/insurance/99999999/coverage-defaults', $admin);
+
+ $this->assertSame(404, $this->responseCode());
+ }
+}
diff --git a/tests/Insurance/CoveragePercentResolutionTest.php b/tests/Insurance/CoveragePercentResolutionTest.php
new file mode 100644
index 00000000..817d84e1
--- /dev/null
+++ b/tests/Insurance/CoveragePercentResolutionTest.php
@@ -0,0 +1,173 @@
+service = static::getContainer()->get(TenantInsuranceService::class);
+ $this->defaults = static::getContainer()->get(InsuranceCoverageDefaultService::class);
+ }
+
+ /** @return array{0: TenantInsurance, 1: Insurance, 2: ServiceItem} */
+ private function makeContract(float $legacyPercent = 55.0): array
+ {
+ $owner = $this->createUser(['ROLE_DOCTOR']);
+ $doctor = new Doctor($owner, 'دکتر تست درصد پوشش');
+ $this->em->persist($doctor);
+
+ $insurance = new Insurance('بیمه پایه ' . random_int(1000, 9999), InsuranceType::Basic);
+ $this->em->persist($insurance);
+ $this->em->flush();
+
+ $contract = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), $insurance->getId());
+ $contract->setCoveragePercent($legacyPercent);
+ $section = new ServiceSection(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 'بخش بستری');
+ $this->em->persist($contract);
+ $this->em->persist($section);
+ $this->em->flush();
+
+ $item = (new ServiceItem($section, 'جراحی', 5_952_000))
+ ->setServiceCategory(ServiceCategory::Inpatient)
+ ->setInsuranceCovered(true);
+ $this->em->persist($item);
+ $this->em->flush();
+
+ return [$contract, $insurance, $item];
+ }
+
+ public function testFallsBackToLegacyContractPercentWhenNothingIsConfigured(): void
+ {
+ [$contract] = $this->makeContract(55.0);
+
+ $this->assertSame(55.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+ }
+
+ public function testAdminDefaultWinsOverLegacyContractPercent(): void
+ {
+ [$contract, $insurance] = $this->makeContract(55.0);
+
+ $this->defaults->save($insurance->getId(), [
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ]);
+
+ $this->assertSame(30.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+ $this->assertSame(70.0, $this->service->resolvePercent($contract, ServiceCategory::Outpatient));
+ }
+
+ /** تغییر تنظیمات مرکزی باید فوراً روی قرارداد بدون override اثر بگذارد (fallback زنده). */
+ public function testChangingTheAdminDefaultImmediatelyChangesTheContract(): void
+ {
+ [$contract, $insurance] = $this->makeContract(55.0);
+
+ $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
+ $this->assertSame(30.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+
+ $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 45]]);
+ $this->assertSame(45.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+ }
+
+ public function testContractCategoryOverrideWinsOverAdminDefault(): void
+ {
+ [$contract, $insurance] = $this->makeContract(55.0);
+ $this->defaults->save($insurance->getId(), [
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ]);
+
+ $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]);
+
+ $this->assertSame(90.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+ $this->assertSame(70.0, $this->service->resolvePercent($contract, ServiceCategory::Outpatient));
+ }
+
+ public function testServiceOverrideWinsOverEveryOtherLevel(): void
+ {
+ [$contract, $insurance, $item] = $this->makeContract(55.0);
+ $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
+ $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]);
+
+ $this->service->setServiceCoverage($contract, $item->getId(), true, 25.0, null, null);
+
+ $rule = $this->service->coverageRuleForService(
+ TenantInsurance::TYPE_DOCTOR,
+ $contract->getEntityId(),
+ $contract->getInsuranceId(),
+ $item->getId(),
+ );
+
+ $this->assertSame(25.0, $rule->coveragePercent);
+ }
+
+ /** درصد نوعِ خودِ خدمت باید انتخاب شود، نه درصد سرپایی. */
+ public function testServiceRuleUsesTheCategoryOfTheServiceItem(): void
+ {
+ [$contract, $insurance, $item] = $this->makeContract(55.0);
+ $this->defaults->save($insurance->getId(), [
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ]);
+
+ $rule = $this->service->coverageRuleForService(
+ TenantInsurance::TYPE_DOCTOR,
+ $contract->getEntityId(),
+ $contract->getInsuranceId(),
+ $item->getId(),
+ );
+
+ $this->assertSame(30.0, $rule->coveragePercent);
+ $this->assertSame(0, $rule->franchiseRials, 'فرانشیز در بیمهٔ پایه صفر میماند');
+ }
+
+ /** ویزیت خدمتِ سرپایی است. */
+ public function testVisitRuleAlwaysUsesTheOutpatientPercent(): void
+ {
+ [$contract, $insurance] = $this->makeContract(55.0);
+ $this->defaults->save($insurance->getId(), [
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ['key' => 'outpatient' , 'coverage_percent' => 70],
+ ]);
+
+ $rule = $this->service->coverageRule(
+ TenantInsurance::TYPE_DOCTOR,
+ $contract->getEntityId(),
+ $contract->getInsuranceId(),
+ );
+
+ $this->assertSame(70.0, $rule->coveragePercent);
+ }
+
+ public function testRemovingTheOverrideReturnsToTheAdminDefault(): void
+ {
+ [$contract, $insurance] = $this->makeContract(55.0);
+ $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
+ $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]);
+
+ $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => null]]);
+
+ $this->assertSame(30.0, $this->service->resolvePercent($contract, ServiceCategory::Inpatient));
+ }
+}
diff --git a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php
new file mode 100644
index 00000000..a1a5e836
--- /dev/null
+++ b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php
@@ -0,0 +1,135 @@
+createUser(['ROLE_DOCTOR']);
+ $doctor = new Doctor($owner, 'دکتر تست پوشش نوع خدمت');
+ $this->em->persist($doctor);
+
+ $insurance = new Insurance('بیمه پایه ' . random_int(1000, 9999), InsuranceType::Basic);
+ $this->em->persist($insurance);
+ $this->em->flush();
+
+ return [$owner, $insurance];
+ }
+
+ public function testContractWithoutOverridesFollowsTheAdminDefault(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+ static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ]);
+
+ $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ ]);
+
+ $this->assertSame(201, $this->responseCode());
+ $row = $body['data']['data'];
+ $this->assertEquals(70.0, $row['category_coverages']['outpatient']);
+ $this->assertEquals(30.0, $row['category_coverages']['inpatient']);
+ $this->assertSame('admin_default', $row['category_coverage_source']['outpatient']);
+ }
+
+ public function testSendingCategoryCoveragesStoresAnOverride(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+ static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ]);
+
+ $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [
+ ['key' => 'inpatient', 'coverage_percent' => 90],
+ ],
+ ]);
+
+ $this->assertSame(201, $this->responseCode());
+ $row = $body['data']['data'];
+ $this->assertEquals(90.0, $row['category_coverages']['inpatient']);
+ $this->assertSame('override', $row['category_coverage_source']['inpatient']);
+ $this->assertSame('admin_default', $row['category_coverage_source']['outpatient']);
+ }
+
+ public function testListReturnsEffectivePercentsAndSources(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+ static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
+ ['key' => 'outpatient', 'coverage_percent' => 55],
+ ]);
+ $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, ['insurance_id' => $insurance->getId()]);
+
+ $body = $this->authJson('GET', '/api/v1/billing/tenant-insurances', $owner);
+
+ $this->assertSame(200, $this->responseCode());
+ $row = $body['data']['data'][0];
+ $this->assertEquals(55.0, $row['category_coverages']['outpatient']);
+ $this->assertArrayHasKey('inpatient', $row['category_coverage_source']);
+ }
+
+ public function testUnknownCategoryIsRejected(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+
+ $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [['key' => 'dental', 'coverage_percent' => 50]],
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ }
+
+ public function testPercentAbove100IsRejected(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+
+ $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [['key' => 'outpatient', 'coverage_percent' => 101]],
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ }
+
+ public function testPatchCanDropAnOverrideBackToTheAdminDefault(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+ static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
+ ['key' => 'inpatient', 'coverage_percent' => 30],
+ ]);
+
+ $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [['key' => 'inpatient', 'coverage_percent' => 90]],
+ ]);
+ $uuid = $created['data']['data']['uuid'];
+
+ $body = $this->authJson('PATCH', '/api/v1/billing/tenant-insurances/' . $uuid, $owner, [
+ 'category_coverages' => [['key' => 'inpatient', 'coverage_percent' => null]],
+ ]);
+
+ $this->assertSame(200, $this->responseCode());
+ $row = $body['data']['data'];
+ $this->assertEquals(30.0, $row['category_coverages']['inpatient']);
+ $this->assertSame('admin_default', $row['category_coverage_source']['inpatient']);
+ }
+}