feat(insurance): resolve coverage percent per service category

Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
@@ -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<typeof itemSchema>;
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<ItemForm>({ 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
</label>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ 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}
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
<div>
<label className="field-label">پکیج کالای مصرفی</label>
<SearchableSelect