diff --git a/.claude/prompt/insurance-coverage-percent-rework.md b/.claude/prompt/insurance-coverage-percent-rework.md new file mode 100644 index 00000000..b7c9daf7 --- /dev/null +++ b/.claude/prompt/insurance-coverage-percent-rework.md @@ -0,0 +1,376 @@ +# اصلاح محاسبه بیمه پایه بر پایه «درصد پوشش» + تنظیمات مرکزی درصدها در پنل ادمین + +## پروژه + +`clinicpro` (بک‌اند Symfony + پنل React ادمین/پزشک). دامنهٔ اصلی: `src/Insurance/`، `src/Billing/`، `src/Patient/`، `assets/admin/`. +سایت عمومی `nobat724_front` هیچ‌جا `insurance-pricing` یا `patient_share_rials` را مصرف نمی‌کند (بررسی شد) → پرامپت همتا لازم نیست. + +## زمینه + +محاسبهٔ سهم بیمهٔ پایه در سیستم بر پایهٔ ترکیبی از «فرانشیز ریالی» و «سهم بیمار ثابت» انجام می‌شود و در نتیجه: + +1. `BillingCalculator` فرانشیز ریالی قرارداد بیمهٔ پایه را به سهم بیمار اضافه می‌کند؛ در حالی‌که قاعدهٔ درست بیمهٔ پایه فقط درصدی است. +2. در `MyPatientsPage` درصد پوشش از روی «سهم بیمار ثابت» (`EntityInsurancePricing.patient_share_rials`) معکوس‌سازی می‌شود (`shareToDiscountPercent`) — یعنی درصد واقعی قرارداد نادیده گرفته می‌شود. +3. هیچ منبع مرکزی برای درصد پوشش بیمه‌های پایه وجود ندارد؛ هر پزشک/کلینیک درصد را دستی وارد می‌کند و بین tenantها اختلاف ایجاد می‌شود. +4. درصد پوشش، تک‌مقداری است؛ تفکیک «خدمات سرپایی / بستری» (و انواع آیندهٔ خدمت) وجود ندارد. + +## هدف + +الف) قاعدهٔ محاسبه در کل سیستم: + +``` +سهم بیمهٔ پایه = round(مبلغ کل × درصد پوشش پایه ÷ 100) +سهم بیمار = مبلغ کل − سهم بیمهٔ پایه (فرانشیز در بیمهٔ پایه دخالت ندارد) +``` + +مثال مرجع کاربر (مبالغ نمایش تومان، ذخیره ریال — ۱ تومان = ۱۰ ریال): + +| مورد | تومان | ریال | +|---|---|---| +| هزینه ویزیت | 595,200 | 5,952,000 | +| درصد پوشش پایه (بستری) | ۳۰٪ | — | +| سهم بیمهٔ پایه | 178,560 | 1,785,600 | +| سهم بیمار | 416,640 | 4,166,400 | + +ب) درصدهای پوشش هر بیمهٔ پایه به‌صورت **مرکزی در پنل ادمین اصلی** (نقش `ROLE_ADMIN`) تعریف شوند: حداقل «سرپایی» و «بستری»، با ساختار توسعه‌پذیر برای انواع بعدی. + +ج) هنگام ایجاد/ویرایش قرارداد بیمه توسط پزشک یا کلینیک، این درصدها **پیش‌فرض بارگذاری** شوند؛ پزشک در حالت عادی چیزی وارد نکند و فقط در صورت داشتن مجوز بتواند برای همان قرارداد override کند. + +## فایل‌های مرتبط + +| فایل | نقش | +|---|---| +| `src/Billing/Service/BillingCalculator.php` | محاسبهٔ سهم پایه/تکمیلی/بیمار — نقطهٔ مرکزی باگ فرانشیز | +| `src/Insurance/ValueObject/CoverageRule.php` | VO قاعدهٔ پوشش (`coveragePercent`, `franchiseRials`, `ceilingRials`) | +| `src/Insurance/Service/TenantInsuranceService.php` | ساخت `CoverageRule` از قرارداد tenant + override خدمت | +| `src/Insurance/Entity/Insurance.php` | کاتالوگ بیمه (ادمین) — محل اتصال درصدهای پیش‌فرض | +| `src/Insurance/Entity/TenantInsurance.php` | قرارداد بیمهٔ پزشک/کلینیک (`coverage_percent`, `franchise_rials`) | +| `src/Insurance/Entity/TenantServiceCoverage.php` | override پوشش در سطح خدمت | +| `src/Insurance/Entity/EntityInsurancePricing.php` | «سهم بیمار ثابت» هر بیمه (مدل قدیمی) | +| `src/Insurance/Controller/InsuranceController.php` | همهٔ اندپوینت‌های بیمه (ادمین + tenant + pricing) | +| `src/Insurance/Enum/InsuranceType.php` | `basic` / `supplementary` | +| `src/ClinicService/Entity/ServiceItem.php` | خدمت — **فاقد** نوع خدمت (سرپایی/بستری) | +| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()` + snapshot درصدها روی مراجعه | +| `src/Billing/Service/InvoiceService.php` | صدور فاکتور از مراجعه با همان CoverageRule | +| `assets/admin/components/InsuranceModal.tsx` | فرم افزودن/ویرایش قرارداد بیمه (پنل پزشک) | +| `assets/admin/components/TenantInsuranceContracts.tsx` | لیست/کارت قراردادهای بیمهٔ tenant | +| `assets/admin/components/ServiceInsuranceModal.tsx` | override پوشش یک خدمت | +| `assets/admin/components/session/CreateStep.tsx` | آینهٔ سمت‌کلاینت `BillingCalculator` (`patientShareOf`) | +| `assets/admin/pages/MyPatientsPage.tsx` | `shareToDiscountPercent` — منبع محاسبهٔ اشتباه | +| `assets/admin/pages/CategoriesPage.tsx` | تب «بیمه‌ها» در پنل ادمین اصلی (CRUD کاتالوگ بیمه) | +| `docs/api/insurance.md`, `docs/api/billing.md`, `docs/api/patient.md` | مستندات API | + +## وضعیت فعلی + +### ۱) فرانشیز به سهم بیمار اضافه می‌شود — `src/Billing/Service/BillingCalculator.php:15-52` + +```php +if ($base !== null && $base->covered) { + $baseShare = $total->percent($base->coveragePercent); + if ($base->ceilingRials !== null) { + $baseShare = $baseShare->min(new Money($base->ceilingRials)); + } + $remaining = $total->sub($baseShare); +} +// ... +// فرانشیز سهم بیمار است؛ از سهم بیمه کم نمی‌کند ولی سهم بیمار از کل بیشتر نمی‌شود. +$franchise = new Money( + ($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0) +); +$patient = $remaining->add($franchise)->min($total); +``` + +### ۲) درصد پوشش از «سهم بیمار ثابت» معکوس می‌شود — `assets/admin/pages/MyPatientsPage.tsx:397-421` + +```tsx +// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. 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; +}; + +const applyBaseInsurance = (insuranceId: string) => { + // ... + form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId)); +}; +``` + +### ۳) قرارداد فقط یک درصد دارد و پزشک باید دستی وارد کند — `assets/admin/components/InsuranceModal.tsx:169-182` + +```tsx +
+
+ + set({ coverage: digitsOnly(e.target.value, 3) })} /> +
+
+ + set({ franchise: digitsOnly(e.target.value) })} /> +
+
+ + ... +
+
+``` + +### ۴) قاعدهٔ پوشش، بی‌خبر از نوع خدمت — `src/Insurance/Service/TenantInsuranceService.php:111-138` + +```php +return new CoverageRule( + coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(), + franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(), + ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(), + covered: true, +); +``` + +`ServiceItem` هیچ فیلدی برای «سرپایی/بستری» ندارد (فیلدها: `name`, `price_rials`, `active`, `insurance_covered`, `insurance_price_rials`, `duration_minutes`, `bookable`, `inventory_package_id`). + +## وظایف + +> ترتیب پیشنهادی: ۱ → ۹ (بک‌اند اول، سپس فرانت، سپس مستندات/تست). هر گام مستقل قابل تست باشد. + +### ۱. Enum نوع خدمت (توسعه‌پذیر) + +فایل جدید `src/Insurance/Enum/ServiceCategory.php`: + +```php +namespace App\Insurance\Enum; + +enum ServiceCategory: string +{ + case Outpatient = 'outpatient'; // سرپایی + case Inpatient = 'inpatient'; // بستری + + public function label(): string + { + return match ($this) { + self::Outpatient => 'خدمات سرپایی', + self::Inpatient => 'خدمات بستری', + }; + } + + /** @return list */ + public static function values(): array + { + return array_map(static fn(self $c) => $c->value, self::cases()); + } +} +``` + +افزودن نوع جدید در آینده = فقط یک `case` تازه؛ هیچ جای دیگری نباید لیست ثابت hardcode شود (نه در Entity، نه در Controller، نه در فرانت — فرانت لیست را از API می‌گیرد). + +### ۲. جدول درصدهای پیش‌فرض ادمین + +Entity جدید `src/Insurance/Entity/InsuranceCoverageDefault.php` + `src/Insurance/Repository/InsuranceCoverageDefaultRepository.php`: + +```php +#[ORM\Entity(repositoryClass: InsuranceCoverageDefaultRepository::class)] +#[ORM\Table(name: 'insurance_coverage_defaults')] +#[ORM\UniqueConstraint(name: 'uniq_insurance_service_category', columns: ['insurance_id', 'service_category'])] +class InsuranceCoverageDefault +{ + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(name: 'insurance_id', type: 'integer')] + private int $insuranceId; + + #[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)] + private ServiceCategory $serviceCategory; + + #[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)] + private string $coveragePercent = '0.00'; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + // getters/setters + toArray() طبق الگوی سایر Entityهای Insurance +} +``` + +متد ریپازیتوری لازم: + +```php +/** @return array service_category => percent */ +public function percentMapFor(int $insuranceId): array; + +/** @return array> insurance_id => (category => percent) — برای پرکردن لیست‌ها بدون N+1 */ +public function percentMapForMany(array $insuranceIds): array; +``` + +سپس: + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +در همان migration، **backfill**: برای هر بیمهٔ `basic` یک ردیف `outpatient` و یک `inpatient` با مقدار ۰ ساخته شود تا پنل ادمین همیشه ردیف کامل نشان دهد (خالی‌بودن = «تعریف‌نشده» نه «صفرِ عمدی»). + +### ۳. اندپوینت‌های ادمین برای درصدهای پیش‌فرض + +در `src/Insurance/Controller/InsuranceController.php` (کنار `/api/v1/admin/insurance/...`، همان `#[IsGranted('ROLE_ADMIN')]` که بقیهٔ اکشن‌های ادمین دارند): + +```php +#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['GET'])] +#[IsGranted('ROLE_ADMIN')] +public function getCoverageDefaults(int $id): JsonResponse +{ + // { categories: [ { key, label, coverage_percent } ] } — همیشه همهٔ caseهای ServiceCategory +} + +#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['PUT'])] +#[IsGranted('ROLE_ADMIN')] +public function saveCoverageDefaults(int $id, Request $request): JsonResponse +{ + // body: { categories: [ { key: 'outpatient', coverage_percent: 30 }, ... ] } + // اعتبارسنجی: key ∈ ServiceCategory::values() و 0 ≤ percent ≤ 100 → در غیر این صورت + // $this->error(ErrorCodes::ERR_VALIDATION_001, ..., 422, 'coverage_percent') +} +``` + +- در پاسخ `GET /api/v1/admin/insurances` (لیست ادمین) هم `coverage_defaults` هر بیمه ضمیمه شود (با `percentMapForMany`، بدون N+1). +- در `GET /api/v1/insurances` و در `pricingPayload()` (خروجی `GET /api/v1/insurance-pricing`) هم برای هر بیمه کلید `coverage_defaults: { outpatient: 30, inpatient: 0 }` اضافه شود — پنل پزشک از همین برای پیش‌فرض استفاده می‌کند. + +### ۴. درصدهای قرارداد در سطح نوع خدمت + +Entity جدید `src/Insurance/Entity/TenantInsuranceCategoryCoverage.php` (`tenant_insurance_category_coverage`): `tenant_insurance_id`, `service_category`, `coverage_percent`, `updated_at` با `UniqueConstraint` روی دو ستون اول. + +- در `POST/PATCH /api/v1/billing/tenant-insurances` بدنه یک آرایهٔ اختیاری بگیرد: + ```json + { "insurance_id": 3, "kind": "basic", + "category_coverages": [ { "key": "outpatient", "coverage_percent": 70 }, + { "key": "inpatient", "coverage_percent": 30 } ] } + ``` +- اگر `category_coverages` ارسال نشد → **هیچ ردیفی ساخته نشود**؛ محاسبه به پیش‌فرض ادمین برمی‌گردد (fallback زنده، نه کپی). این مهم است: با تغییر قوانین بیمه در پنل ادمین، قراردادهایی که override نکرده‌اند خودبه‌خود به‌روز می‌شوند. +- `GET /api/v1/billing/tenant-insurances` برای هر قرارداد برگرداند: + ```json + { "coverage_percent": 70, + "category_coverages": { "outpatient": 70, "inpatient": 30 }, + "category_coverage_source": { "outpatient": "override", "inpatient": "admin_default" } } + ``` + تا فرانت بتواند نشان دهد کدام مقدار از تنظیمات مرکزی آمده است. +- مجوز override: همان `insurances.update` که در کنترلر با `secretaryAccess`/`clinicDoctorAccess` چک می‌شود؛ اگر کاربر مجوز ندارد و `category_coverages` فرستاده، `ERR_FORBIDDEN_001` برگردد. +- `TenantInsuranceCleanupService` (حذف قرارداد) باید ردیف‌های این جدول را هم پاک کند. + +### ۵. نوع خدمت روی `ServiceItem` + +- ستون جدید `service_category` روی `src/ClinicService/Entity/ServiceItem.php` با `enumType: ServiceCategory::class` و پیش‌فرض `outpatient`؛ در `toArray()` هم برگردد. +- ویزیت (که آیتم سرویس نیست) **همیشه `outpatient`** است؛ این را به‌صورت ثابت در `PatientService::calculateFinalPrice()` و `InvoiceService::createFromSession()` اعمال کن، نه با مقدار جادویی پراکنده. +- در `ServiceItemFormModal.tsx` یک `SearchableSelect` (طبق قاعدهٔ پروژه: هرگز `` از `SearchableSelect`؛ صفحه/مودال جدید با همان تم و کامپوننت‌های موجود، بدون طراحی تازه. +- **پاسخ‌های تودرتو:** `tenant-insurances` در فرانت با `(data as any)?.data?.data` خوانده می‌شود — اگر شکل پاسخ را تغییر دادی، هر سه مصرف‌کننده (`TenantInsuranceContracts`, `CreateStep`, `MyPatientsPage`) را هم‌زمان اصلاح کن. +- بعد از اتمام: `graphify update .` (پس از commit). diff --git a/assets/admin/components/InsuranceCoverageDefaultsModal.tsx b/assets/admin/components/InsuranceCoverageDefaultsModal.tsx new file mode 100644 index 00000000..e6980f1e --- /dev/null +++ b/assets/admin/components/InsuranceCoverageDefaultsModal.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { digitsOnly } from '../lib/utils'; +import Modal from './ui/Modal'; + +interface CoverageDefaultRow { + key: string; + label: string; + coverage_percent: number; +} + +interface Props { + /** بیمهٔ هدف؛ `null` یعنی مودال بسته است. */ + insurance: { id: number; name: string; type: 'basic' | 'supplementary' } | null; + onClose: () => void; +} + +/** + * تنظیمات مرکزی درصد پوشش یک بیمه به تفکیک نوع خدمت (سرپایی/بستری/…). + * لیست نوع‌ها از سرور می‌آید؛ قراردادهای پزشک/کلینیک که override نکرده‌اند + * همین مقادیر را زنده می‌خوانند. + */ +export default function InsuranceCoverageDefaultsModal({ insurance, onClose }: Props) { + const qc = useQueryClient(); + const [percents, setPercents] = useState>({}); + + const { data, isLoading } = useQuery>({ + queryKey: ['insurance-coverage-defaults', insurance?.id], + queryFn: () => api.get(`/api/v1/admin/insurance/${insurance!.id}/coverage-defaults`), + enabled: insurance !== null, + }); + + const rows = data?.data?.categories ?? []; + + useEffect(() => { + if (rows.length === 0) return; + setPercents(Object.fromEntries(rows.map((r) => [r.key, String(r.coverage_percent)]))); + }, [data]); + + const save = useMutation({ + mutationFn: () => api.put(`/api/v1/admin/insurance/${insurance!.id}/coverage-defaults`, { + categories: rows.map((r) => ({ key: r.key, coverage_percent: Number(percents[r.key] || 0) })), + }), + onSuccess: () => { + toast.success('درصدهای پوشش ذخیره شد'); + qc.invalidateQueries({ queryKey: ['admin-insurances'] }); + qc.invalidateQueries({ queryKey: ['insurance-coverage-defaults', insurance?.id] }); + qc.invalidateQueries({ queryKey: ['insurance-pricing'] }); + onClose(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const invalid = rows.some((r) => Number(percents[r.key] || 0) > 100); + + return ( + + + + + } + > +

+ {insurance?.type === 'supplementary' + ? 'سهم بیمهٔ تکمیلی روی «باقیماندهٔ پس از بیمهٔ پایه» اعمال می‌شود.' + : 'سهم بیمهٔ پایه از مبلغ کل خدمت محاسبه می‌شود؛ باقیمانده سهم بیمار است.'} + {' '}این مقادیر پیش‌فرض همهٔ پزشکان و کلینیک‌ها است و قراردادهایی که درصد اختصاصی + تعیین نکرده‌اند، با تغییر همین اعداد به‌روز می‌شوند. +

+ + {isLoading ? ( +

در حال بارگذاری...

+ ) : ( +
+ {rows.map((row) => ( +
+ + setPercents((p) => ({ ...p, [row.key]: digitsOnly(e.target.value, 3) }))} + placeholder="مثلاً: ۳۰" + /> + {Number(percents[row.key] || 0) > 100 && ( +

درصد نمی‌تواند بیشتر از ۱۰۰ باشد

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/assets/admin/components/InsuranceModal.test.tsx b/assets/admin/components/InsuranceModal.test.tsx index 99ba1a3d..fdde95a5 100644 --- a/assets/admin/components/InsuranceModal.test.tsx +++ b/assets/admin/components/InsuranceModal.test.tsx @@ -4,29 +4,43 @@ import { renderWithProviders } from '../test/utils'; import InsuranceModal, { buildInsurancePayload, contractToForm, EMPTY_FORM, type Contract, type InsuranceOption, } from './InsuranceModal'; +import type { ServiceCategoryOption } from '../hooks/useServiceCategories'; const mkContract = (over: Partial = {}): Contract => ({ uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000, - effective_to: null, ...over, + effective_to: null, + category_coverages: { outpatient: 70, inpatient: 30 }, + category_coverage_source: { outpatient: 'override', inpatient: 'admin_default' }, + ...over, }); const options: InsuranceOption[] = [ - { insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' }, - { insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' }, + { insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic', coverage_defaults: { outpatient: 70, inpatient: 30 } }, + { insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary', coverage_defaults: { outpatient: 40, inpatient: 20 } }, +]; + +const categories: ServiceCategoryOption[] = [ + { key: 'outpatient', label: 'خدمات سرپایی' }, + { key: 'inpatient', label: 'خدمات بستری' }, ]; describe('buildInsurancePayload', () => { - it('converts toman → rials, percent, and Y-m-d → unix', () => { + it('converts toman → rials, per-category percents, and Y-m-d → unix', () => { const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', kind: 'supplementary', - coverage: '80', franchise: '50000', ceiling: '2000000', + categoryPercents: { outpatient: '80', inpatient: '30' }, + franchise: '50000', ceiling: '2000000', effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01', }); expect(payload.insurance_id).toBe(3); expect(payload.kind).toBe('supplementary'); - expect(payload.coverage_percent).toBe(80); + expect(payload.coverage_percent).toBe(80); // ستون قدیمی = درصد سرپایی + expect(payload.category_coverages).toEqual([ + { key: 'outpatient', coverage_percent: 80 }, + { key: 'inpatient', coverage_percent: 30 }, + ]); expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10 expect(payload.annual_ceiling_rials).toBe(20_000_000); expect(typeof payload.effective_from).toBe('number'); @@ -34,56 +48,95 @@ describe('buildInsurancePayload', () => { }); it('empty ceiling → null (بی‌نهایت), empty dates → null', () => { - const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', coverage: '50' }); + const payload = buildInsurancePayload({ + ...EMPTY_FORM, insuranceId: '3', categoryPercents: { outpatient: '50' }, + }); expect(payload.annual_ceiling_rials).toBeNull(); expect(payload.effective_from).toBeNull(); expect(payload.effective_to).toBeNull(); }); + + it('forces franchise to zero on a basic contract', () => { + const payload = buildInsurancePayload({ + ...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '50000', + categoryPercents: { outpatient: '70' }, + }); + expect(payload.franchise_rials).toBe(0); + }); + + it('omits category_coverages when the user may not override them', () => { + const payload = buildInsurancePayload( + { ...EMPTY_FORM, insuranceId: '3', categoryPercents: { outpatient: '70' } }, + null, + false, + ); + expect(payload).not.toHaveProperty('category_coverages'); + }); }); describe('contractToForm', () => { - it('maps rials → toman and uses contract kind', () => { + it('maps rials → toman, contract kind, and effective category percents', () => { const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' })); expect(form.franchise).toBe('30000'); expect(form.kind).toBe('supplementary'); - expect(form.coverage).toBe('70'); + expect(form.categoryPercents).toEqual({ outpatient: '70', inpatient: '30' }); }); }); describe('InsuranceModal', () => { - it('renders the fields in add mode with no manual kind select', () => { + it('renders one percent input per category and hides franchise on basic', () => { renderWithProviders( - {}} onSubmit={() => {}} />, + {}} onSubmit={() => {}} />, ); expect(screen.getByText('افزودن بیمه')).toBeInTheDocument(); expect(screen.getByText('نام بیمه')).toBeInTheDocument(); // Manual "نوع بیمه" select is gone; kind is shown as a read-only chip from the tab. expect(screen.queryByText('نوع بیمه')).not.toBeInTheDocument(); expect(screen.getByText('پایه')).toBeInTheDocument(); - expect(screen.getByText('تاریخ شروع قرارداد')).toBeInTheDocument(); - expect(screen.getByText('تاریخ پایان قرارداد')).toBeInTheDocument(); - expect(screen.getByText('درصد پوشش')).toBeInTheDocument(); - expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument(); + expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument(); + expect(screen.getByText('درصد پوشش — خدمات بستری')).toBeInTheDocument(); + expect(screen.queryByText('فرانشیز (تومان)')).not.toBeInTheDocument(); expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument(); expect(screen.getByText('ثبت بیمه')).toBeInTheDocument(); }); - it('shows the tab kind chip and carries it into a new payload', () => { - const onSubmit = vi.fn(); + it('shows the franchise field for a supplementary contract', () => { renderWithProviders( - {}} onSubmit={onSubmit} />, + {}} onSubmit={() => {}} />, ); expect(screen.getByText('تکمیلی')).toBeInTheDocument(); + expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument(); + }); + + it('prefills the percents of an edited contract and marks admin defaults', () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ); + expect(screen.getByLabelText('درصد پوشش خدمات سرپایی')).toHaveValue('70'); + expect(screen.getByLabelText('درصد پوشش خدمات بستری')).toHaveValue('30'); + expect(screen.getByText('پیش‌فرض ادمین')).toBeInTheDocument(); + }); + + it('makes the percents read-only without the update permission', () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ); + expect(screen.getByLabelText('درصد پوشش خدمات سرپایی')).toHaveAttribute('readonly'); + expect(screen.getAllByText('مقدار پیش‌فرض تنظیمات مرکزی').length).toBe(2); }); it('submits the built payload for an edited contract', () => { const onSubmit = vi.fn(); renderWithProviders( - {}} onSubmit={onSubmit} />, + {}} onSubmit={onSubmit} />, ); fireEvent.click(screen.getByText('ثبت بیمه')); expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ - insurance_id: 3, coverage_percent: 70, franchise_rials: 500_000, kind: 'basic', + insurance_id: 3, coverage_percent: 70, franchise_rials: 0, kind: 'basic', + category_coverages: [ + { key: 'outpatient', coverage_percent: 70 }, + { key: 'inpatient', coverage_percent: 30 }, + ], })); }); }); diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx index b7c45eab..768f634b 100644 --- a/assets/admin/components/InsuranceModal.tsx +++ b/assets/admin/components/InsuranceModal.tsx @@ -4,11 +4,14 @@ import SearchableSelect from './ui/SearchableSelect'; import PersianDateInput from './ui/PersianDateInput'; import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils'; import { digitsOnly } from '../lib/utils'; +import type { ServiceCategoryOption } from '../hooks/useServiceCategories'; export interface InsuranceOption { insurance_id: number; insurance_name: string; type: string; + /** درصدهای پیش‌فرض تنظیمات مرکزی ادمین به تفکیک نوع خدمت. */ + coverage_defaults?: Record; } export interface Contract { @@ -24,6 +27,10 @@ export interface Contract { kind: string | null; effective_from: number; effective_to: number | null; + /** درصد مؤثر هر نوع خدمت (override قرارداد یا پیش‌فرض مرکزی). */ + category_coverages?: Record; + /** منبع هر درصد: `override` | `admin_default` | `contract`. */ + category_coverage_source?: Record; } export interface InsuranceFormValues { @@ -31,7 +38,8 @@ export interface InsuranceFormValues { kind: string; effectiveFrom: string; // Y-m-d effectiveTo: string; // Y-m-d - coverage: string; + /** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */ + categoryPercents: Record; franchise: string; // toman ceiling: string; // toman } @@ -41,9 +49,11 @@ export const KIND_LABEL: Record = { supplementary: 'تکمیلی', }; +export const SOURCE_ADMIN_DEFAULT = 'admin_default'; + export const EMPTY_FORM: InsuranceFormValues = { insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '', - coverage: '', franchise: '', ceiling: '', + categoryPercents: {}, franchise: '', ceiling: '', }; /** Map a contract to editable form values (rials → toman, unix → Y-m-d). */ @@ -53,26 +63,45 @@ export function contractToForm(c: Contract): InsuranceFormValues { kind: c.kind ?? c.insurance_kind ?? 'basic', effectiveFrom: unixToIso(c.effective_from), effectiveTo: unixToIso(c.effective_to), - coverage: String(c.coverage_percent ?? ''), + categoryPercents: percentsToStrings(c.category_coverages), franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '', ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '', }; } +function percentsToStrings(map?: Record): Record { + return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)])); +} + /** * Build the API payload from form values (toman → rials, Y-m-d → unix). * When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor * clinic); otherwise it falls back to the caller's own tenant on the backend. + * + * `category_coverages` is only sent when the user may override percentages — the + * backend rejects it otherwise, and omitting it keeps the contract on the central + * admin defaults. فرانشیز فقط در قرارداد تکمیلی معنا دارد. */ -export function buildInsurancePayload(v: InsuranceFormValues, doctorUuid?: string | null) { +export function buildInsurancePayload( + v: InsuranceFormValues, + doctorUuid?: string | null, + includeCategoryCoverages = true, +) { + const isBasic = v.kind !== 'supplementary'; + const percents = Object.entries(v.categoryPercents); + return { insurance_id: Number(v.insuranceId), kind: v.kind || null, - coverage_percent: Number(v.coverage) || 0, - franchise_rials: tomanToRial(Number(v.franchise) || 0), + // ستون قدیمی قرارداد؛ آخرین سطح fallback است و با درصد سرپایی همگام می‌ماند. + coverage_percent: Number(v.categoryPercents.outpatient ?? percents[0]?.[1] ?? 0) || 0, + franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0), annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)), effective_from: isoToUnix(v.effectiveFrom), effective_to: isoToUnix(v.effectiveTo), + ...(includeCategoryCoverages + ? { category_coverages: percents.map(([key, percent]) => ({ key, coverage_percent: Number(percent) || 0 })) } + : {}), ...(doctorUuid ? { doctor_uuid: doctorUuid } : {}), }; } @@ -82,10 +111,14 @@ interface Props { editContract: Contract | null; /** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */ options: InsuranceOption[]; + /** انواع خدمت از سرور — یک ورودی درصد به ازای هر نوع رندر می‌شود. */ + categories: ServiceCategoryOption[]; /** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */ kind: string; /** Target doctor in a multi-doctor clinic; threaded into the payload as `doctor_uuid`. */ doctorUuid?: string | null; + /** بدون این مجوز، درصدها فقط خواندنی‌اند و اصلاً ارسال نمی‌شوند. */ + canUpdate?: boolean; onClose: () => void; onSubmit: (payload: ReturnType) => void; isPending?: boolean; @@ -93,10 +126,13 @@ interface Props { /** * Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form - * state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه" - * modal plus the injected coverage/franchise/ceiling controls. + * state, emits the built payload via onSubmit. درصدهای پوشش به تفکیک نوع خدمت و + * پیش‌فرض‌گرفته از تنظیمات مرکزی ادمین. */ -export default function InsuranceModal({ open, editContract, options, kind, doctorUuid, onClose, onSubmit, isPending }: Props) { +export default function InsuranceModal({ + open, editContract, options, categories, kind, doctorUuid, canUpdate = true, + onClose, onSubmit, isPending, +}: Props) { const [form, setForm] = useState(EMPTY_FORM); useEffect(() => { @@ -107,13 +143,27 @@ export default function InsuranceModal({ open, editContract, options, kind, doct const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); const isEdit = editContract !== null; + // در حالت افزودن، انتخاب بیمه درصدها را از تنظیمات مرکزی همان بیمه پر می‌کند. + const pickInsurance = (value: string) => { + const picked = options.find((i) => String(i.insurance_id) === value); + set({ + insuranceId: value, + categoryPercents: value === '' ? {} : percentsToStrings(picked?.coverage_defaults), + }); + }; + + const setPercent = (key: string, raw: string) => + setForm((f) => ({ ...f, categoryPercents: { ...f.categoryPercents, [key]: digitsOnly(raw, 3) } })); + const submit = () => { if (!form.insuranceId) return; - onSubmit(buildInsurancePayload(form, doctorUuid)); + onSubmit(buildInsurancePayload(form, doctorUuid, canUpdate)); }; const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 }; const label = { fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }; + const isBasic = form.kind !== 'supplementary'; + const sourceOf = (key: string) => editContract?.category_coverage_source?.[key]; return ( ({ value: String(i.insurance_id), label: i.insurance_name }))} value={form.insuranceId} - onChange={(v) => set({ insuranceId: v ? String(v) : '' })} + onChange={(v) => pickInsurance(v ? String(v) : '')} isDisabled={isEdit} placeholder="انتخاب کنید..." /> @@ -166,15 +216,38 @@ export default function InsuranceModal({ open, editContract, options, kind, doct -
-
- - set({ coverage: digitsOnly(e.target.value, 3) })} /> -
-
- - set({ franchise: digitsOnly(e.target.value) })} /> -
+
+ {categories.map((c) => ( +
+ + setPercent(c.key, e.target.value)} + /> + + {!canUpdate + ? 'مقدار پیش‌فرض تنظیمات مرکزی' + : sourceOf(c.key) === SOURCE_ADMIN_DEFAULT + ? 'پیش‌فرض ادمین' + : ' '} + +
+ ))} +
+ +
+ {!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']); + } +}