feat(insurance): resolve coverage percent per service category

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
@@ -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
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>درصد پوشش</label>
<input ... value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
</div>
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input ... value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
<div style={field}>
<label style={label}>سقف تعهد (تومان)</label>
...
</div>
</div>
```
### ۴) قاعدهٔ پوشش، بی‌خبر از نوع خدمت — `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<string> */
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<string, float> service_category => percent */
public function percentMapFor(int $insuranceId): array;
/** @return array<int, array<string, float>> 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` (طبق قاعدهٔ پروژه: هرگز `<select>` بومی) برای «نوع خدمت» با گزینه‌های گرفته‌شده از API اضافه شود.
- migration + backfill: همهٔ ردیف‌های موجود = `outpatient`.
### ۶. اصلاح `CoverageRule` و `BillingCalculator`
`CoverageRule` نوع خدمت را نمی‌شناسد و نباید بشناسد؛ فقط منطق فرانشیز اصلاح می‌شود:
```php
final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
public int $franchiseRials, // فقط برای بیمهٔ تکمیلی معنا دارد
public ?int $ceilingRials,
public bool $covered = true,
) {}
}
```
در `BillingCalculator::calculateItem()` فرانشیزِ **پایه** حذف شود:
```php
// بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ تکمیلی معنا دارد.
$franchise = new Money($supplementary?->franchiseRials ?? 0);
$patient = $remaining->add($franchise)->min($total);
```
و `TenantInsuranceService::coverageRule*()` هنگام ساخت قاعدهٔ پایه `franchiseRials: 0` بدهد (ستون DB برای سازگاری باقی می‌ماند ولی در محاسبهٔ پایه دخالت نمی‌کند).
سناریوی مرجع باید دقیقاً برقرار شود: `calculateItem(new Money(5_952_000), new CoverageRule(30.0, 0, null), null)` →
`base_insurance_rials = 1_785_600` و `patient_rials = 4_166_400`.
### ۷. زنجیرهٔ resolve درصد پوشش
در `TenantInsuranceService` یک متد خصوصی اضافه شود و هر دو متد `coverageRule()` / `coverageRuleForService()` از آن استفاده کنند:
```php
/**
* درصد پوشش مؤثر، به ترتیب اولویت:
* ۱) override همان خدمت (TenantServiceCoverage.coverage_percent)
* ۲) override قرارداد برای نوع خدمت (TenantInsuranceCategoryCoverage)
* ۳) پیش‌فرض مرکزی ادمین (InsuranceCoverageDefault)
* ۴) coverage_percent قرارداد (سازگاری با ردیف‌های قدیمی)
*/
private function resolvePercent(
TenantInsurance $contract,
ServiceCategory $category,
?TenantServiceCoverage $override,
): float
```
`coverageRule()` (ویزیت) با `ServiceCategory::Outpatient` صدا زده شود؛ `coverageRuleForService()` با `service_category` همان `ServiceItem`.
`PatientService::contractPercent()` (snapshot روی `base_insurance_discount_percent` مراجعه) هم از همین زنجیره تغذیه شود — این فیلد فقط snapshot نمایشی است و نباید ورودی محاسبه باشد (همان‌طور که در docblock فعلی نوشته شده).
### ۸. پنل ادمین اصلی — بخش تنظیمات درصدهای هر بیمه
در `assets/admin/pages/CategoriesPage.tsx` (تب «بیمه‌ها»، اطراف خط ۸۷۰ به بعد):
- در هر ردیف بیمه یک اکشن «تنظیمات پوشش» اضافه شود که مودالی باز کند (از `components/ui/Modal` موجود؛ طراحی جدید نساز — همان تم/کامپوننت‌های موجود).
- مودال از `GET /api/v1/admin/insurance/{id}/coverage-defaults` می‌خواند و یک ردیف ورودی درصد به‌ازای هر category برمی‌گرداند (لیست از API، نه hardcode) و با `PUT` ذخیره می‌کند؛ سپس `queryKey` لیست بیمه‌ها invalidate شود.
- اعتبارسنجی کلاینت: عدد ۰ تا ۱۰۰، با `digitsOnly(value, 3)` مثل `InsuranceModal`.
- برای بیمهٔ تکمیلی هم همین بخش نمایش داده شود (ساختار یکسان)، ولی متن راهنما شفاف کند که سهم تکمیلی روی **باقیماندهٔ پس از پایه** اعمال می‌شود.
### ۹. پنل پزشک/کلینیک — پیش‌فرض‌گیری از تنظیمات مرکزی
`assets/admin/components/InsuranceModal.tsx`:
- `InsuranceFormValues` به‌جای `coverage: string` تک‌مقداری، `categoryPercents: Record<string, string>` بگیرد.
- با انتخاب بیمه در حالت «افزودن»، مقادیر از `coverage_defaults` همان بیمه (از `pricingQuery`) پر شوند؛ در حالت «ویرایش»، از `category_coverages` قرارداد.
- ورودی‌های درصد فقط وقتی قابل ویرایش باشند که `canUpdate` (از `usePermissions`) درست باشد؛ در غیر این صورت `readOnly` با متن راهنما «مقدار پیش‌فرض تنظیمات مرکزی».
- زیر هر ورودی که هنوز override نشده، برچسب کوچک «پیش‌فرض ادمین» نمایش داده شود (`category_coverage_source`).
- فیلد «فرانشیز (تومان)» برای قراردادهای `basic` حذف شود (در `buildInsurancePayload` برای basic همیشه `franchise_rials: 0`)؛ برای `supplementary` باقی بماند.
`assets/admin/components/TenantInsuranceContracts.tsx`:
- `contractSummary()` به‌جای «پوشش X٪» درصدها را به تفکیک نشان دهد: `سرپایی ۷۰٪ · بستری ۳۰٪ · سقف پوشش …`؛ فرانشیز فقط در قراردادهای تکمیلی.
- در `ContractDetails` به‌ازای هر category یک `DetailCell` رندر شود.
`assets/admin/pages/MyPatientsPage.tsx`:
- `shareToDiscountPercent` **حذف** شود. `applyBaseInsurance`/`applySuppInsurance` درصد را از قرارداد فعال همان بیمه (`/api/v1/billing/tenant-insurances`) بگیرند، با category پیش‌فرض `outpatient` برای ویزیت.
- `patient_share_rials` دیگر ورودی محاسبه نیست؛ نقش `EntityInsurancePricing` فقط «قیمت ویزیت آزاد» و `require_visit_price` باقی می‌ماند.
`assets/admin/components/session/CreateStep.tsx`:
- `patientShareOf` (آینهٔ سرور) با قاعدهٔ جدید هم‌تراز شود: فرانشیز پایه حذف، و `ruleFor()` درصد را بر اساس `service_category` خدمت انتخاب کند.
- `coverageOf(id)` برای ویزیت درصد `outpatient` را برگرداند.
### ۱۰. مستندات و تست
- `docs/api/insurance.md`: دو اندپوینت جدید coverage-defaults، فیلدهای جدید در `GET /api/v1/insurances`، `GET /api/v1/insurance-pricing`، و `GET/POST/PATCH /api/v1/billing/tenant-insurances`.
- `docs/api/billing.md` و `docs/api/patient.md`: فرمول جدید سهم پایه/بیمار و بی‌اثرشدن فرانشیز در بیمهٔ پایه.
- `docs/api/clinic-services.md`: فیلد `service_category` روی خدمت.
- PHPUnit:
- `BillingCalculator`: سناریوی مرجع ۵,۹۵۲,۰۰۰ ریال با ۳۰٪؛ پایه+تکمیلی پشت‌سرهم؛ سقف تعهد؛ درصد ۰ و ۱۰۰؛ `notCovered`.
- `TenantInsuranceService::resolvePercent`: هر چهار سطح زنجیره + رفتار fallback پس از تغییر پیش‌فرض ادمین.
- Controller: `PUT coverage-defaults` با درصد ۱۰۱ → ۴۲۲؛ کاربر غیر ادمین → ۴۰۳.
- Vitest: `InsuranceModal` (پیش‌فرض‌گیری از `coverage_defaults`، readOnly بدون مجوز)، `contractSummary`، `patientShareOf` در `CreateStep`.
- اجرای واقعی: `ddev exec php bin/phpunit`، `ddev exec php vendor/bin/phpstan analyse`، `yarn test`، `npx tsc --noEmit --project tsconfig.json`.
## نکات مهم
- **واحد پول:** ذخیره‌سازی و API ریال است؛ ورودی/نمایش پنل تومان (`tomanToRial` / `rialToToman` / `formatRial`). سناریوی کاربر با تومان نوشته شده — در تست‌ها ریال بگذار.
- **گرد کردن:** یک قاعده در کل سیستم — `Money::percent()` سمت سرور و `Math.round(total * percent / 100)` سمت کلاینت. سهم بیمار همیشه `total baseShare` است، نه یک محاسبهٔ درصدیِ مستقل؛ در غیر این صورت مجموع دو سهم با کل برابر نمی‌شود.
- **منبع واحد محاسبه:** فقط `BillingCalculator`. `PatientService::calculateFinalPrice()` و `InvoiceService::createFromSession()` هر دو از آن عبور می‌کنند و نباید محاسبهٔ موازی اضافه شود. کد فرانت آینه است و حق ندارد به تنهایی مبنای ثبت باشد.
- **سازگاری با دادهٔ موجود:** ستون‌های `tenant_insurances.coverage_percent` و `franchise_rials` حذف نشوند (سطح ۴ زنجیره + قراردادهای تکمیلی). فقط از مسیر محاسبهٔ بیمهٔ پایه خارج می‌شوند.
- **fallback زنده، نه کپی:** قرارداد بدون override نباید درصد را در جدول خودش snapshot کند؛ در غیر این صورت هدف «مدیریت مرکزی» نقض می‌شود.
- **کالاهای مصرفی** (`SessionConsumable`) بدون پوشش بیمه‌اند — این رفتار تغییر نکند.
- **`ServiceItem.insurance_covered`** همچنان gate نهایی است: خدمتی که این پرچم را ندارد، هرچقدر هم درصد تعریف شده باشد، `notCovered` می‌ماند.
- **الگوهای پروژه:** پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()`؛ لیست‌های ادمین با `getArrayResult()`؛ تاریخ‌ها Unix timestamp؛ رشته‌های UI فارسی؛ در فرانت به‌جای `<select>` از `SearchableSelect`؛ صفحه/مودال جدید با همان تم و کامپوننت‌های موجود، بدون طراحی تازه.
- **پاسخ‌های تودرتو:** `tenant-insurances` در فرانت با `(data as any)?.data?.data` خوانده می‌شود — اگر شکل پاسخ را تغییر دادی، هر سه مصرف‌کننده (`TenantInsuranceContracts`, `CreateStep`, `MyPatientsPage`) را هم‌زمان اصلاح کن.
- بعد از اتمام: `graphify update .` (پس از commit).
@@ -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<Record<string, string>>({});
const { data, isLoading } = useQuery<ApiResponse<{ insurance_id: number; categories: CoverageDefaultRow[] }>>({
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 (
<Modal
open={insurance !== null}
title={`تنظیمات پوشش — ${insurance?.name ?? ''}`}
size="sm"
onClose={onClose}
footer={
<>
<button onClick={onClose} className="btn ghost sm">لغو</button>
<button
onClick={() => save.mutate()}
disabled={save.isPending || isLoading || invalid}
className="btn primary sm"
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.8, color: 'var(--text-2)' }}>
{insurance?.type === 'supplementary'
? 'سهم بیمهٔ تکمیلی روی «باقیماندهٔ پس از بیمهٔ پایه» اعمال می‌شود.'
: 'سهم بیمهٔ پایه از مبلغ کل خدمت محاسبه می‌شود؛ باقیمانده سهم بیمار است.'}
{' '}این مقادیر پیشفرض همهٔ پزشکان و کلینیکها است و قراردادهایی که درصد اختصاصی
تعیین نکردهاند، با تغییر همین اعداد بهروز میشوند.
</p>
{isLoading ? (
<p className="muted" style={{ fontSize: 12.5 }}>در حال بارگذاری...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((row) => (
<div key={row.key} className="form-row">
<label>{row.label} (درصد)</label>
<input
className="input"
inputMode="numeric"
value={percents[row.key] ?? ''}
onChange={(e) => setPercents((p) => ({ ...p, [row.key]: digitsOnly(e.target.value, 3) }))}
placeholder="مثلاً: ۳۰"
/>
{Number(percents[row.key] || 0) > 100 && (
<p className="err-text">درصد نمیتواند بیشتر از ۱۰۰ باشد</p>
)}
</div>
))}
</div>
)}
</Modal>
);
}
+73 -20
View File
@@ -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> = {}): 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(
<InsuranceModal open editContract={null} options={options} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
<InsuranceModal open editContract={null} options={options} categories={categories} kind="basic" onClose={() => {}} 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(
<InsuranceModal open editContract={null} options={options} kind="supplementary" onClose={() => {}} onSubmit={onSubmit} />,
<InsuranceModal open editContract={null} options={options} categories={categories} kind="supplementary" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
});
it('prefills the percents of an edited contract and marks admin defaults', () => {
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" onClose={() => {}} 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(
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" canUpdate={false} onClose={() => {}} 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(
<InsuranceModal open editContract={mkContract()} options={options} kind="basic" onClose={() => {}} onSubmit={onSubmit} />,
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" onClose={() => {}} 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 },
],
}));
});
});
+93 -20
View File
@@ -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<string, number>;
}
export interface Contract {
@@ -24,6 +27,10 @@ export interface Contract {
kind: string | null;
effective_from: number;
effective_to: number | null;
/** درصد مؤثر هر نوع خدمت (override قرارداد یا پیش‌فرض مرکزی). */
category_coverages?: Record<string, number>;
/** منبع هر درصد: `override` | `admin_default` | `contract`. */
category_coverage_source?: Record<string, string>;
}
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<string, string>;
franchise: string; // toman
ceiling: string; // toman
}
@@ -41,9 +49,11 @@ export const KIND_LABEL: Record<string, string> = {
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<string, number>): Record<string, string> {
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<typeof buildInsurancePayload>) => 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<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
@@ -107,13 +143,27 @@ export default function InsuranceModal({ open, editContract, options, kind, doct
const set = (patch: Partial<InsuranceFormValues>) => 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 (
<Modal
@@ -149,7 +199,7 @@ export default function InsuranceModal({ open, editContract, options, kind, doct
<SearchableSelect
options={options.map((i) => ({ 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
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>درصد پوشش</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
</div>
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, categories.length)}, 1fr)`, gap: 12 }}>
{categories.map((c) => (
<div key={c.key} style={field}>
<label style={label}>درصد پوشش {c.label}</label>
<input
type="text"
inputMode="numeric"
dir="ltr"
className="input"
readOnly={!canUpdate}
aria-label={`درصد پوشش ${c.label}`}
value={form.categoryPercents[c.key] ?? ''}
onChange={(e) => setPercent(c.key, e.target.value)}
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
{!canUpdate
? 'مقدار پیش‌فرض تنظیمات مرکزی'
: sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
? 'پیش‌فرض ادمین'
: ' '}
</span>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: isBasic ? '1fr' : '1fr 1fr', gap: 12 }}>
{!isBasic && (
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
)}
<div style={field}>
<label style={label}>سقف تعهد (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بی‌نهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
@@ -11,6 +11,7 @@ import type { ServiceItem, ClinicStaff } from '../types';
import type { InventoryPackage, InventoryItem } from '../hooks/useInventory';
import { rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
@@ -21,6 +22,8 @@ const itemSchema = z.object({
staff_uuids: z.array(z.string()).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
bookable: z.boolean().optional(),
/** نوع خدمت (سرپایی/بستری) — مبنای انتخاب درصد پوشش بیمه. */
service_category: z.string().min(1, 'نوع خدمت الزامی است'),
/** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */
inventory_package_uuid: z.string().optional(),
/** اقلام کالای تکی — مستقل از پکیج. */
@@ -28,9 +31,12 @@ const itemSchema = z.object({
});
type ItemForm = z.infer<typeof itemSchema>;
const DEFAULT_SERVICE_CATEGORY = 'outpatient';
const EMPTY_FORM: ItemForm = {
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
bookable: false, inventory_package_uuid: '', consumables: [],
bookable: false, service_category: DEFAULT_SERVICE_CATEGORY,
inventory_package_uuid: '', consumables: [],
};
interface Props {
@@ -75,6 +81,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
});
const inventoryItems = inventoryData?.data?.items ?? [];
const { categories } = useServiceCategories(item !== null);
const form = useForm<ItemForm>({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
useEffect(() => {
@@ -86,6 +94,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
duration_minutes: editing.duration_minutes ?? undefined,
bookable: editing.bookable ?? false,
service_category: editing.service_category ?? DEFAULT_SERVICE_CATEGORY,
inventory_package_uuid: editing.inventory_package_uuid ?? '',
consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })),
}
@@ -242,6 +251,21 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
</label>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ value: c.key, label: c.label }))}
value={form.watch('service_category') || null}
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
placeholder="انتخاب نوع خدمت"
noOptionsMessage="نوعی تعریف نشده است"
height={42}
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
<div>
<label className="field-label">پکیج کالای مصرفی</label>
<SearchableSelect
@@ -38,18 +38,39 @@ describe('filterInsurances', () => {
});
});
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', () => {
@@ -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() {
<ContractCard
key={c.uuid}
contract={c}
categories={categories}
open={expanded === c.uuid}
onToggleRow={() => 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 (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
@@ -277,27 +295,45 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
</button>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c, categories)}</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
)}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} categories={categories} /></div>}
</div>
);
}
/** 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 (
<div style={{
background: 'var(--surface-2)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)',
padding: 14,
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
}}>
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
{categories.filter((cat) => cat.key in percents).map((cat) => (
<DetailCell
key={cat.key}
label={`درصد پوشش — ${cat.label}`}
value={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{formatNumber(percents[cat.key])}٪
{c.category_coverage_source?.[cat.key] === SOURCE_ADMIN_DEFAULT && (
<span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)' }}>پیشفرض ادمین</span>
)}
</span>
}
/>
))}
{isSupplementary && (
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
)}
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
<DetailCell label="تاریخ پایان قرارداد" value={c.effective_to != null ? formatDate(c.effective_to) : 'بدون تاریخ پایان'} />
@@ -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<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
@@ -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);
});
});
+42 -15
View File
@@ -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<string, number> }
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));
@@ -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<ApiResponse<ServiceCategoryOption[]>>({
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 };
}
+21
View File
@@ -4,6 +4,7 @@ import {
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
PhotoIcon, XMarkIcon, ArrowDownTrayIcon, ArrowUpTrayIcon, ExclamationTriangleIcon,
AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -18,7 +19,10 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import InsuranceCoverageDefaultsModal from '../components/InsuranceCoverageDefaultsModal';
import { useServiceCategories } from '../hooks/useServiceCategories';
import { numericField } from '../lib/forms';
import { formatNumber } from '../lib/utils';
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
@@ -861,6 +865,7 @@ function InsurancesTab() {
const [deleteTarget, setDeleteTarget] = useState<Insurance | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [uploadTarget, setUploadTarget] = useState<number | null>(null);
const [coverageTarget, setCoverageTarget] = useState<Insurance | null>(null);
const [idSort, setIdSort] = useState<'asc' | 'desc' | null>(null);
const sortQs = idSort ? `&sort=id&order=${idSort}` : '';
@@ -906,6 +911,10 @@ function InsurancesTab() {
reset({ name: i.name, type: i.type, status: String(i.status) });
};
// برچسب نوع خدمت از سرور می‌آید؛ افزودن نوع تازه نیازی به تغییر این صفحه ندارد.
const { categories } = useServiceCategories();
const categoryLabel = (key: string) => categories.find((c) => c.key === key)?.label ?? key;
const columns: Column<Insurance>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (i) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{i.id}</span> },
{ key: 'name', header: 'نام', render: (i) => (
@@ -921,6 +930,15 @@ function InsurancesTab() {
</div>
)},
{ key: 'type', header: 'نوع', render: (i) => <span className={`badge ${i.type === 'basic' ? 'green' : 'blue'}`}><span className="bdot" />{i.type === 'basic' ? 'پایه' : 'تکمیلی'}</span> },
{ key: 'coverage_defaults', header: 'درصد پوشش', render: (i) => {
const entries = Object.entries(i.coverage_defaults ?? {});
if (entries.length === 0) return <span className="muted"></span>;
return (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{entries.map(([key, percent]) => `${categoryLabel(key)} ${formatNumber(percent)}٪`).join(' · ')}
</span>
);
}},
{ key: 'status', header: 'وضعیت', render: (i) => <SBadge status={i.status} /> },
];
@@ -930,11 +948,14 @@ function InsurancesTab() {
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمه‌ها..." emptyMessage="هیچ بیمه‌ای یافت نشد"
actions={(i) => (
<>
<button onClick={() => setCoverageTarget(i)} className="mini-btn" title="تنظیمات پوشش"><AdjustmentsHorizontalIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => openEdit(i)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(i)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
<InsuranceCoverageDefaultsModal insurance={coverageTarget} onClose={() => setCoverageTarget(null)} />
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن بیمه'} size="sm" onClose={closeModal}
+26 -11
View File
@@ -83,6 +83,7 @@ interface PricingInsurance {
insurance_id: number;
insurance_name: string;
type: string;
/** فقط «سهم بیمار ثابت» مدل قدیمی؛ ورودی هیچ محاسبه‌ای نیست. */
patient_share_rials: number | null;
}
interface InsurancePricing {
@@ -90,6 +91,9 @@ interface InsurancePricing {
insurances: PricingInsurance[];
}
/** ویزیت خدمتِ سرپایی است، پس درصد پوشش همین نوع خدمت خوانده می‌شود. */
const VISIT_SERVICE_CATEGORY = "outpatient";
const PAYMENT_LABELS: Record<string, string> = {
cash: "نقدی",
card: "کارت",
@@ -350,6 +354,13 @@ function MyPatientsPageInner() {
enabled: !!selectedRecord,
});
// قراردادهای بیمهٔ همین tenant — منبع درصد پوشش (نه سهم بیمار ثابت).
const { data: contractsData } = useQuery<ApiResponse<any>>({
queryKey: ["tenant-insurances"],
queryFn: () => api.get("/api/v1/billing/tenant-insurances"),
enabled: !!selectedRecord,
});
const { data: invoiceData } = useQuery<ApiResponse<any>>({
queryKey: ["invoice", invoiceUuid],
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
@@ -393,21 +404,25 @@ function MyPatientsPageInner() {
.filter((i) => i.type === "supplementary")
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. share=null یعنی پوشش ندارد (۰٪).
const shareToDiscountPercent = (insuranceId: string): number => {
if (!insuranceId || freeVisitPrice <= 0) return 0;
const ins = pricing?.insurances.find((i) => String(i.insurance_id) === insuranceId);
if (!ins || ins.patient_share_rials == null) return 0;
const covered = Math.max(0, freeVisitPrice - ins.patient_share_rials);
return Math.round((covered / freeVisitPrice) * 1000) / 10;
/**
* درصد پوشش ویزیت از قرارداد فعال همان بیمه (سرپایی). قرارداد نبود → ۰٪.
* `patient_share_rials` دیگر ورودی محاسبه نیست.
*/
const contractVisitPercent = (insuranceId: string): number => {
if (!insuranceId) return 0;
const contracts = (contractsData?.data as any)?.data ?? [];
const contract = contracts.find(
(c: any) => String(c.insurance_id) === insuranceId && c.is_active,
);
return Number(contract?.category_coverages?.[VISIT_SERVICE_CATEGORY] ?? contract?.coverage_percent ?? 0);
};
const applyBaseInsurance = (insuranceId: string) => {
setBaseInsuranceId(insuranceId);
form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId && freeVisitPrice > 0) {
form.setValue("visit_price_rials", freeVisitPrice);
form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId));
if (insuranceId) {
if (freeVisitPrice > 0) form.setValue("visit_price_rials", freeVisitPrice);
form.setValue("base_insurance_discount_percent", contractVisitPercent(insuranceId));
}
};
@@ -415,7 +430,7 @@ function MyPatientsPageInner() {
setSuppInsuranceId(insuranceId);
form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId) {
form.setValue("supplementary_discount_percent", shareToDiscountPercent(insuranceId));
form.setValue("supplementary_discount_percent", contractVisitPercent(insuranceId));
}
};
+5
View File
@@ -415,6 +415,8 @@ export interface Insurance {
type: "basic" | "supplementary";
logo_url: string | null;
status: number;
/** درصد پوشش مرکزی به تفکیک نوع خدمت — `{ outpatient: 70, inpatient: 30 }`. */
coverage_defaults?: Record<string, number>;
}
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;
}
+14 -2
View File
@@ -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` قرارداد پایه در محاسبه **بی‌اثر** است (ستون برای سازگاری و قراردادهای تکمیلی می‌ماند).
نمونه‌ها:
- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
---
+26 -1
View File
@@ -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`)
+147 -12
View File
@@ -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`.
+3 -3
View File
@@ -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` با وجود نامش به‌صورت **سقف هر قلم** اعمال می‌شود؛ انباشت سالانه‌ای در کد وجود ندارد.
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Central admin-managed coverage percentages per insurance and service category.
*/
final class Version20260725115603 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add insurance_coverage_defaults (admin-managed coverage percent per insurance and service category)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Per-service-category coverage overrides a tenant sets on its own insurance contract.
* Rows exist only where the tenant overrode the central admin default.
*/
final class Version20260725120831 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tenant_insurance_category_coverage (contract-level coverage percent per service category)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Service kind (outpatient/inpatient) a service item belongs to. The insurance
* coverage percentage is resolved per this value.
*/
final class Version20260725121548 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add service_items.service_category (defaults every existing row to outpatient)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+1 -1
View File
@@ -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
-
+5 -6
View File
@@ -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,
@@ -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;
+9
View File
@@ -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,
@@ -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),
];
+103 -11
View File
@@ -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<array<string, mixed>>
*/
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<string, mixed> 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'])]
@@ -0,0 +1,65 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceCoverageDefaultRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Central (admin-managed) coverage percentage of an insurance per service category.
* Single source of truth: tenant contracts fall back to these values live, so a
* change here immediately applies to every doctor/clinic that has not overridden it.
*/
#[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;
public function __construct(int $insuranceId, ServiceCategory $serviceCategory, float $coveragePercent = 0.0)
{
$this->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,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantInsuranceCategoryCoverageRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Per-service-category coverage percentage a tenant explicitly overrode on its own
* contract. A missing row means "follow the central admin default", so contracts
* are never snapshots of it.
*/
#[ORM\Entity(repositoryClass: TenantInsuranceCategoryCoverageRepository::class)]
#[ORM\Table(name: 'tenant_insurance_category_coverage')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_insurance_category', columns: ['tenant_insurance_id', 'service_category'])]
class TenantInsuranceCategoryCoverage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'tenant_insurance_id', type: 'integer')]
private int $tenantInsuranceId;
#[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;
public function __construct(int $tenantInsuranceId, ServiceCategory $serviceCategory, float $coveragePercent = 0.0)
{
$this->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,
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Insurance\Enum;
/**
* Service kind an insurance coverage percentage is defined for.
* Adding a new kind is a single case here: every list (admin settings, contract
* overrides, service form) is derived from these cases, never hardcoded.
*/
enum ServiceCategory: string
{
case Outpatient = 'outpatient';
case Inpatient = 'inpatient';
public function label(): string
{
return match ($this) {
self::Outpatient => 'خدمات سرپایی',
self::Inpatient => 'خدمات بستری',
};
}
/** @return list<string> */
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;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\InsuranceCoverageDefault;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InsuranceCoverageDefaultRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InsuranceCoverageDefault::class);
}
/** @return InsuranceCoverageDefault[] */
public function findByInsurance(int $insuranceId): array
{
return $this->findBy(['insuranceId' => $insuranceId]);
}
public function findOneFor(int $insuranceId, ServiceCategory $category): ?InsuranceCoverageDefault
{
return $this->findOneBy(['insuranceId' => $insuranceId, 'serviceCategory' => $category]);
}
/** @return array<string, float> 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<int> $insuranceIds
* @return array<int, array<string, float>> 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();
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantInsuranceCategoryCoverageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantInsuranceCategoryCoverage::class);
}
public function findOneFor(int $tenantInsuranceId, ServiceCategory $category): ?TenantInsuranceCategoryCoverage
{
return $this->findOneBy(['tenantInsuranceId' => $tenantInsuranceId, 'serviceCategory' => $category]);
}
/** @return array<string, float> 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<int> $tenantInsuranceIds
* @return array<int, array<string, float>> 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();
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\InsuranceCoverageDefault;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceCoverageDefaultRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* Admin-managed coverage percentages. Every read returns a complete category set
* (missing rows read as 0) so callers never have to know which categories exist.
*/
class InsuranceCoverageDefaultService
{
public function __construct(
private readonly InsuranceCoverageDefaultRepository $repo,
) {}
/** @return array<string, float> service_category => percent, all categories present */
public function percentMap(int $insuranceId): array
{
return $this->fill($this->repo->percentMapFor($insuranceId));
}
/**
* @param list<int> $insuranceIds
* @return array<int, array<string, float>>
*/
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<array{key: string, label: string, coverage_percent: float}>
*/
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<array{key?: string, coverage_percent?: mixed}> $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<string, float> $stored */
private function fill(array $stored): array
{
$map = [];
foreach (ServiceCategory::cases() as $category) {
$map[$category->value] = $stored[$category->value] ?? 0.0;
}
return $map;
}
}
@@ -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(
+197 -15
View File
@@ -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<array{key?: string, coverage_percent?: mixed}> $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<string, float>, category_coverage_source: array<string, string>}
*/
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<int, array{category_coverages: array<string, float>, category_coverage_source: array<string, string>}>
*/
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<string, float> $overrides
* @param array<string, float> $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)
@@ -6,6 +6,7 @@ final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
/** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمی‌کند. */
public int $franchiseRials,
public ?int $ceilingRials,
public bool $covered = true,
+7 -3
View File
@@ -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;
+43 -3
View File
@@ -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());
@@ -0,0 +1,90 @@
<?php
namespace App\Tests\ClinicService;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* `service_category` classifies a service as outpatient/inpatient; the insurance
* coverage percentage is resolved per that value. The category list is served by the
* API so no client hardcodes the enum cases.
*/
class ServiceCategoryApiTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: ServiceSection} */
private function makeDoctorAndSection(): array
{
$owner = $this->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());
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Tests\Insurance;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Tests\ApiTestCase;
/**
* Admin-only endpoints managing the central coverage percentages of an insurance.
*/
class CoverageDefaultsApiTest extends ApiTestCase
{
private function makeInsurance(): Insurance
{
$insurance = new Insurance('بیمه تست پوشش ' . random_int(1000, 9999), InsuranceType::Basic);
$this->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());
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Tests\Insurance;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Insurance\Service\TenantInsuranceService;
use App\Tests\ApiTestCase;
/**
* The effective coverage percentage is resolved through a four-level chain:
* service override contract category override central admin default
* legacy contract percentage. A contract that overrode nothing must follow the
* admin default live, which is what makes central management work at all.
*/
class CoveragePercentResolutionTest extends ApiTestCase
{
private TenantInsuranceService $service;
private InsuranceCoverageDefaultService $defaults;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Tests\Insurance;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Tests\ApiTestCase;
/**
* Contract-level per-category coverage overrides over the tenant insurance API:
* sending them stores an override, omitting them keeps the contract on the central
* admin default (live fallback).
*/
class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Insurance} */
private function makeDoctorAndInsurance(): 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();
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']);
}
}