+
درصد پوشش — {c.label}
+
setPercent(c.key, e.target.value)} />
+ …
+ {!isBasic && (
+
+ فرانشیز (تومان)
+ set({ franchise: digitsOnly(e.target.value) })} />
+
+ )}
+```
+
+و در `buildInsurancePayload()`:
+
+```ts
+ franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
+```
+
+`clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` — مودال از لیست سراسری تغذیه میشود
+و فرانشیز ریالی نمایش داده میشود:
+
+```tsx
+ const { categories } = useServiceCategories(); // همهٔ نوعها، نه نوعهای فعالِ tenant
+ …
+ if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) {
+ parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
+ }
+```
+
+`clinicpro/src/Billing/Repository/ClaimRepository.php` — نه بیمهای در SELECT سطح بیمار هست و نه
+جستجو/فیلتر نوع بیمه:
+
+```php
+ if (!empty($filters['search'])) {
+ $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
+ $params['search'] = '%' . trim((string) $filters['search']) . '%';
+ }
+```
+
+## وظایف
+
+### ۱. مهاجرت داده: `franchise_rials` → `franchise_percent`
+
+`TenantInsurance`:
+
+```php
+ #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)]
+ private string $franchisePercent = '0.00';
+
+ public function getFranchisePercent(): float { return (float) $this->franchisePercent; }
+ public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; }
+```
+
+`TenantServiceCoverage`: همان تغییر با `nullable: true` و getter/setter `?float`.
+هر دو `toArray()` کلید `franchise_percent` بدهند (کلید `franchise_rials` کاملاً حذف شود — سازگاری
+عقبرو لازم نیست چون تنها مصرفکننده پنل خودِ همین ریپوست).
+
+Migration دستی بنویس (نه فقط `diff`)، چون تبدیل ریال به درصد معنا ندارد:
+
+```php
+$this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent DECIMAL(5,2) NOT NULL DEFAULT 0');
+$this->addSql('UPDATE tenant_insurances SET franchise_percent = 0');
+$this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent DECIMAL(5,2) DEFAULT NULL');
+$this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL');
+```
+
+در `getDescription()` صریح بنویس که مقادیر ریالیِ قبلی قابل تبدیل نیستند و صفر میشوند.
+
+**نحوه تست:** `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` سپس
+`ddev exec php bin/console doctrine:schema:validate` باید mapping را سبز بدهد.
+
+### ۲. فرمول فرانشیز درصدی
+
+`CoverageRule`:
+
+```php
+final readonly class CoverageRule
+{
+ public function __construct(
+ public float $coveragePercent,
+ /** درصدِ سهم بیمار از «باقیماندهٔ بعد از بیمهٔ پایه»؛ فقط در قرارداد تکمیلی معنا دارد. */
+ public float $franchisePercent,
+ public ?int $ceilingRials,
+ public bool $covered = true,
+ ) {}
+
+ public static function notCovered(): self { return new self(0.0, 0.0, null, false); }
+}
+```
+
+`BillingCalculator::calculateItem()` — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته میشود:
+
+```php
+ $suppBase = $remaining; // باقیماندهٔ بعد از بیمهٔ پایه
+ $suppShare = Money::zero();
+ if ($supplementary !== null && $supplementary->covered) {
+ $suppShare = $suppBase->percent($supplementary->coveragePercent);
+ if ($supplementary->ceilingRials !== null) {
+ $suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
+ }
+ $remaining = $suppBase->sub($suppShare);
+ }
+
+ // فرانشیز درصدی است، نه مبلغ: سهمِ بیمار از همان مبلغی که تکمیلی رویش کار میکند.
+ $franchise = $supplementary !== null && $supplementary->covered
+ ? $suppBase->percent($supplementary->franchisePercent)
+ : Money::zero();
+ $patient = $remaining->add($franchise)->min($total);
+```
+
+`TenantInsuranceService::buildRule()` و `activate()` و `setServiceCoverage()` را به
+`franchisePercent`/`franchise_percent` ببر و اعتبارسنجی ۰..۱۰۰ را (همان الگوی
+`setCategoryCoverages()`) اضافه کن:
+
+```php
+ if ($franchisePercent < 0 || $franchisePercent > 100) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent');
+ }
+```
+
+`InsuranceController` هر سه نقطهٔ `franchise_rials` (خطوط ۵۰۲، ۵۶۴–۵۶۵، ۶۸۵) را با
+`franchise_percent` و cast به `float` جایگزین کند.
+
+**نحوه تست:** یک تست واحد در `tests/Billing/` برای `BillingCalculator` با سه سناریو:
+(الف) پایه ۳۰٪ + تکمیلی ۹۰٪ + فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ ریال، (ب) فرانشیز ۱۰۰٪،
+(ج) بیمهٔ پایه با فرانشیز غیرصفر (باید بیاثر بماند). در هر سه،
+`total === base + supp + patient` را assert کن. علاوه بر آن:
+`ddev exec php bin/phpunit tests/Patient/SessionInsuranceShareTest.php` باید سبز بماند
+(مقادیر انتظاریاش را طبق فرمول جدید بهروز کن، نه با تغییر فرمول).
+
+### ۳. الزامیکردن درصد پوشش برای نوع خدمتِ فعال
+
+**بکاند** — در `InsuranceController::applyCategoryCoverages()` قبل از فراخوانی سرویس، بررسی کن که
+برای هر نوع خدمتِ فعالِ همان tenant یک درصد معتبر آمده باشد:
+
+```php
+ $enabled = $this->serviceCategories->enabledKeys($entityType, $entityId);
+ $sent = [];
+ foreach ($data['category_coverages'] ?? [] as $row) {
+ $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null;
+ }
+ foreach ($enabled as $key) {
+ if (($sent[$key] ?? null) === null || $sent[$key] === '' || (float) $sent[$key] <= 0) {
+ return $this->error(
+ ErrorCodes::ERR_VALIDATION_001,
+ sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()),
+ 422,
+ 'category_coverages',
+ );
+ }
+ }
+```
+
+توجه: `applyCategoryCoverages()` امروز `$entityType/$entityId` ندارد — امضایش را گسترش بده
+(هر دو فراخوان `activate` و `update` این مقادیر را در دست دارند). این چک فقط وقتی اجرا شود که
+کلید `category_coverages` در payload آمده باشد؛ PATCHـهای دیگر (مثل toggle وضعیت) نباید بشکنند.
+
+**فرانت** — `TenantInsuranceContracts.tsx` بهجای `useServiceCategories()` نوع خدمتِ **فعالِ tenant**
+را از همان `pricingQuery` که از قبل fetch میشود بگیرد (بدون درخواست جدید):
+
+```tsx
+ const enabledCategories: ServiceCategoryOption[] = useMemo(() => (
+ ((pricingQuery.data as any)?.data?.service_categories ?? [])
+ .filter((c: { enabled: boolean }) => c.enabled)
+ .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label }))
+ ), [pricingQuery.data]);
+```
+
+و همین را به `categories` مودال بدهد. `useServiceCategories` اگر مصرفکنندهٔ دیگری ندارد دستنخورده
+بماند (فرم خدمت از آن استفاده میکند — قبل از حذف grep کن).
+
+`InsuranceModal.tsx`: دکمهٔ ثبت تا وقتی هر نوع خدمتِ رندرشده مقدار ۱..۱۰۰ نداشته باشد `disabled` بماند،
+و زیر ورودیِ خالی پیام «الزامی است» با `var(--danger)` نشان داده شود:
+
+```tsx
+ const percentsValid = categories.every((c) => {
+ const n = Number(form.categoryPercents[c.key]);
+ return Number.isFinite(n) && n > 0 && n <= 100;
+ });
+ …
+ disabled={!form.insuranceId || !percentsValid || isPending}
+```
+
+(وقتی `canUpdate === false` درصدها فقطخواندنی و ارسالنشدنیاند — در آن حالت این اجبار اعمال نشود.)
+
+**نحوه تست:**
+- API: با توکن پزشک `09389388131`، `PUT /api/v1/insurance-pricing` با
+ `service_categories: [{key:'outpatient',enabled:true},{key:'inpatient',enabled:false}]`، سپس
+ `POST /api/v1/billing/tenant-insurances` یکبار بدون درصد (انتظار ۴۲۲) و یکبار با
+ `[{key:'outpatient',coverage_percent:70}]` (انتظار ۲۰۱).
+- UI: `assets/admin/components/InsuranceModal.test.tsx` را با کیس «ثبت غیرفعال است تا درصد پر شود»
+ و «فقط نوع خدمتِ فعال رندر میشود» گسترش بده؛ `yarn test`.
+
+### ۴. فرانشیز درصدی در پنل
+
+- `InsuranceModal.tsx`: برچسب «فرانشیز (درصد)»، ورودی `digitsOnly(value, 3)` با اعتبارسنجی ≤۱۰۰،
+ `contractToForm()` از `c.franchise_percent` بخواند (بدون `rialToToman`)، و
+ `buildInsurancePayload()` بفرستد:
+
+ ```ts
+ franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
+ ```
+
+ `Contract` و `InsuranceFormValues` هم بهروز شوند (`franchise_percent: number`).
+- `TenantInsuranceContracts.tsx`: `contractSummary()` → ``فرانشیز ${formatNumber(c.franchise_percent)}٪``
+ و `ContractDetails` → مقدار `٪`دار.
+- `insuranceShares.ts`: `CoverageRule.franchise` به معنی درصد شود و `patientShareOf()` دقیقاً آینهٔ
+ فرمول جدید سرور باشد (پایهٔ فرانشیز = باقیماندهٔ بعد از پایه). این فایل تنها منبع محاسبه در پنل است؛
+ هیچ صفحهای فرمول موازی ننویسد.
+- `CreateStep.tsx`، `ServiceInsuranceModal.tsx`، `ServiceDetailPage.tsx`: `franchise_rials` →
+ `franchise_percent`؛ در `ServiceInsuranceModal` ورودی از `PriceInput` به ورودی درصد تغییر کند و
+ در `ServiceDetailPage` نمایش `فرانشیز {formatRial(...)}` به `فرانشیز {formatNumber(...)}٪` تبدیل شود.
+
+**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` (هیچ ارجاع باقیماندهای به `franchise_rials`
+نباید بماند — با grep هم تأیید کن) و `yarn test` (فایلهای تستِ متأثر:
+`InsuranceModal.test.tsx`, `TenantInsuranceContracts.test.tsx`, `session/CreateStep.test.tsx`,
+`appointments/ConfirmAppointmentModal.test.tsx`, `appointments/TurnsTimeline.test.tsx`,
+`pages/AppointmentEditPage.test.tsx`).
+
+### ۵. بیمه در داشبورد مطالبات
+
+**Repository** — در `patientAggregateSql()` و `detailsForPatient()` هر دو، join کاتالوگ اضافه شود
+(برای جستجوی نام بیمه) و در نمای تجمیعی، بیمههای هر بیمار جمع شوند:
+
+```sql
+ LEFT JOIN insurances ins ON ins.id = c.insurance_id
+ …
+ GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, ins.name, c.insurance_kind) SEPARATOR '~') AS insurances,
+```
+
+سپس در `aggregateByPatient()` به آرایهٔ ساختیافته تبدیل شود:
+
+```php
+ 'insurances' => array_values(array_filter(array_map(static function (string $chunk): ?array {
+ [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null);
+ return $id === null || $id === '' ? null : [
+ 'insurance_id' => (int) $id,
+ 'insurance_name' => $name,
+ 'kind' => $kind, // base | supplementary
+ ];
+ }, explode('~', (string) $r['insurances'])))),
+```
+
+در `patientAggregateFilters()`:
+
+```php
+ if (!empty($filters['kind'])) {
+ $conditions[] = 'c.insurance_kind = :kind';
+ $params['kind'] = $filters['kind'];
+ }
+ if (!empty($filters['search'])) {
+ $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search'
+ . ' OR u.national_code LIKE :search OR ins.name LIKE :search)';
+ $params['search'] = '%' . trim((string) $filters['search']) . '%';
+ }
+```
+
+⚠️ همان WHERE در `countPatientsWithClaims()` و `detailsForPatient()` هم استفاده میشود؛ join جدید باید
+در **هر سه** SQL باشد وگرنه شمارش با لیست واگرا میشود یا کوئری با «Unknown column ins.name» میشکند.
+شرط محیطِ `c.entity_type = :type AND c.entity_id = :id` را دست نزن (کامنت هشدارِ همان فایل).
+
+**Controller** — `claimFilters()` کلید `kind` را بگیرد و مقدارش را اعتبارسنجی کند
+(`base` | `supplementary`، در غیر این صورت `ERR_VALIDATION_001` با ۴۲۲ و پیام «نوع بیمه نامعتبر است»).
+
+**فرانت** — `ClaimsPage.tsx`:
+
+```tsx
+const KIND_OPTIONS = [
+ { value: '', label: 'همه انواع' },
+ { value: 'base', label: 'پایه' },
+ { value: 'supplementary', label: 'تکمیلی' },
+];
+```
+
+- فیلتر نوع بیمه با `SearchableSelect` (طبق قاعدهٔ پروژه؛ `
` بومی ممنوع) کنار فیلتر بیمه،
+ با ذخیره در query string مثل بقیهٔ فیلترها.
+- ستون جدید بین «بیمار» و «تعداد درخواست»:
+
+ ```tsx
+ { key: 'insurances', header: 'بیمه', render: (r) => (
+ r.insurances.length === 0 ? '—' :
+
+ {r.insurances.map((i) => (
+
+ {i.insurance_name ?? `#${i.insurance_id}`}
+
+ ))}
+
+ ) },
+ ```
+
+- `searchPlaceholder` به «نام، موبایل، کد ملی بیمار یا نام بیمه» بهروز شود.
+- `ClaimPatientRow` در `types/index.ts` فیلد `insurances: { insurance_id: number; insurance_name: string | null; kind: string }[]` بگیرد.
+
+**نحوه تست:** با توکن پزشک سناریو:
+`GET /api/v1/billing/claims/by-patient?limit=50` → هر ردیف `insurances` غیرخالی؛
+`?kind=supplementary` → فقط بیماران دارای مطالبهٔ تکمیلی؛ `?search=آسیا` → فقط بیماران آن بیمه؛
+`?kind=bogus` → ۴۲۲. و مقایسهٔ `meta.totalRecords` با تعداد ردیفها در حالت بدون صفحهبندی.
+
+### ۶. سناریوی داده + تست دستی برای پزشک 09389388131
+
+یک Console Command بساز: `src/Insurance/Command/SeedInsuranceScenarioCommand.php`
+(`app:seed-insurance-scenario`)، با آپشنهای `--doctor-mobile=09389388131` و `--purge`.
+
+منطق (همه از مسیر سرویسهای واقعی، نه INSERT خام — تا اعداد را همان `BillingCalculator` بسازد):
+
+1. پزشک را از موبایل پیدا کن (`users.mobile_number` → `doctors.user_id`). نبود پزشک = خطای واضح.
+ محیط: `entity_type='doctor'`, `entity_id = doctors.id` (برای این موبایل: `11548`).
+2. هر دو نوع خدمت (`outpatient`, `inpatient`) را برای این tenant فعال کن
+ (`TenantServiceCategoryService::save()`).
+3. یک بیمهٔ پایه (`تامین اجتماعی`, id 176) + سه بیمهٔ تکمیلی از کاتالوگ فعال کن — مثلاً
+ `بیمه ایران` (182)، `بیمه آسیا` (183)، `بیمه دی` (184) — با
+ `TenantInsuranceService::activate()` و درصدهای متفاوت، و برای هر کدام
+ `setCategoryCoverages()` با درصد سرپایی و بستریِ متفاوت:
+
+ | بیمه | نوع | سرپایی | بستری | فرانشیز | سقف سالانه |
+ |---|---|---|---|---|---|
+ | تامین اجتماعی | پایه | ۳۰٪ | ۴۰٪ | — | نامحدود |
+ | بیمه ایران | تکمیلی | ۹۰٪ | ۸۰٪ | ۱۰٪ | ۵۰٬۰۰۰٬۰۰۰ ریال |
+ | بیمه آسیا | تکمیلی | ۷۰٪ | ۶۰٪ | ۲۰٪ | نامحدود |
+ | بیمه دی | تکمیلی | ۱۰۰٪ | ۵۰٪ | ۰٪ | ۱۰٬۰۰۰٬۰۰۰ ریال |
+
+4. چهار بیمار با موبایل نشاندار `091299000{1..4}` بساز (marker مخصوص همین سناریو تا `--purge`
+ بتواند دقیقاً همانها را پاک کند)، برای هرکدام `PatientRecord` در همین محیط، و مراجعه/صورتحساب
+ با ترکیبهای متفاوت: بیمار۱ فقط پایه، بیمار۲ پایه + بیمه ایران، بیمار۳ فقط بیمه آسیا،
+ بیمار۴ پایه + بیمه دی با خدمتی که سقف را رد میکند (تست سقف).
+5. صورتحسابها را نهایی کن و با `ClaimService::createFromInvoice()` مطالبه بساز؛ سپس چند مطالبه را
+ با `ClaimService::transition()` جلو ببر: یکی `submitted`، یکی `approved`، یکی `paid`،
+ یکی `rejected` با دلیل — تا هر پنج وضعیتِ فیلتر داده داشته باشد.
+6. در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.
+
+**نحوه تست:**
+
+```bash
+ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
+ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 # بار دوم: بدون دادهی تکراری
+```
+
+سپس ورود به پنل با `09389388131` و بررسی دستی:
+- `/admin/insurance-pricing`: تب «بیمه تکمیلی» سه قرارداد، هر کدام با «سرپایی X٪ · بستری Y٪ · فرانشیز Z٪ · سقف …»؛
+ باز کردن مودال ویرایش → فرانشیز درصدی، و اگر یکی از نوعهای خدمت را غیرفعال کنی آن ورودی محو شود.
+- `/admin/claims`: ستون «بیمه» پر، فیلتر «نوع بیمه» کار کند، جستجوی «آسیا» فقط بیمار۳ را بدهد،
+ کارتهای آمار با جمع ستونها همخوان باشند.
+- ورود به جزئیات یک بیمار: سهمها با فرمول فرانشیز درصدی همخوان باشند
+ (`مبلغ اصلی − سهم بیمه = سهم بیمار`).
+
+اسکرینشات هر دو صفحه را بعد از سناریو ضمیمه کن.
+
+### ۷. مستندات
+
+`docs/api/insurance.md` و `docs/api/billing.md` را در همین session بهروز کن:
+- تغییر فیلد `franchise_rials` → `franchise_percent` در
+ `POST/PATCH /api/v1/billing/tenant-insurances` و
+ `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` (با محدودهٔ ۰..۱۰۰).
+- اجباریبودن `category_coverages` برای نوع خدمتِ فعال + خطای ۴۲۲ مربوطه.
+- پارامتر جدید `kind` و رفتار تازهٔ `search` در `GET /api/v1/billing/claims/by-patient` و فیلد
+ خروجی `insurances`.
+- فرمول جدید محاسبهٔ سهم (همان بلوک شبهکد بالا) در بخش محاسبهٔ صورتحساب.
+
+## نکات مهم
+
+- **ترتیب اجرا:** اول migration + مدل + فرمول (وظایف ۱–۲)، بعد بکاندِ اعتبارسنجی (۳)، بعد فرانت (۴–۵)،
+ و سناریو (۶) در آخر — سناریو باید روی کد نهایی اجرا شود تا اعدادش با فرمول جدید ساخته شوند.
+- `franchise_rials` هیچجا نباید باقی بماند: بعد از پایان کار
+ `grep -rn "franchise_rials" src/ assets/ docs/ migrations/` فقط باید فایل migration جدید و
+ migrationهای تاریخی `Version20260622*.php` را نشان بدهد.
+- **قاعدهٔ فرانشیز فقط تکمیلی:** در `buildRule()` و در `insuranceShares.ts::ruleOf()` این شرط
+ (`kind === 'supplementary'`) دستنخورده بماند؛ تغییر واحد از ریال به درصد است، نه تغییر دامنهٔ اثر.
+- **دو واژگان جدا:** قرارداد `kind` = `basic`/`supplementary` ولی مطالبه `insurance_kind` =
+ `base`/`supplementary`. در ClaimsPage از `base` استفاده کن (نه `basic`)؛ این تفاوت واقعی است
+ و در `ClaimPatientDetailPage` هم همینطور نگاشت شده.
+- **SQL خام و tenant:** کوئریهای `ClaimRepository` زیر `TenantFilter` نیستند؛ افزودن join نباید
+ شرط `c.entity_type/:type` را جابهجا یا مشروط کند.
+- **الگوها:** کد جدید همان الگوی موجود را نگه دارد — controller نازک، منطق در Service، کوئری در
+ Repository، پاسخها با `$this->success()/paginated()/error()`، تاریخها `int` یونیکس. الگوی طراحیِ
+ تازه لازم نیست؛ `CoverageRule` همچنان همان Value Object است و فقط واحد یکی از فیلدهایش عوض میشود.
+- **UI:** هیچ طراحی جدیدی نساز — از `Modal`، `SearchableSelect`، `DataTable`، `StatusBadge`، توکنهای
+ `styles.css` و کلاسهای موجود (`badge`, `btn`, `input`, `field-label`) استفاده کن. رشتهها فارسی،
+ اعداد با `formatNumber` و درصد با `٪`.
+- **بدون تست سبز تمام نیست:** `ddev exec php bin/phpunit` و `yarn test` و
+ `npx tsc --noEmit --project tsconfig.json` هر سه باید پاس شوند، و
+ `ddev exec php vendor/bin/phpstan analyse` رگرسیون جدید ندهد.
diff --git a/assets/admin/components/InsuranceModal.test.tsx b/assets/admin/components/InsuranceModal.test.tsx
index fdde95a5..9d2d5041 100644
--- a/assets/admin/components/InsuranceModal.test.tsx
+++ b/assets/admin/components/InsuranceModal.test.tsx
@@ -8,7 +8,7 @@ import type { ServiceCategoryOption } from '../hooks/useServiceCategories';
const mkContract = (over: Partial = {}): Contract => ({
uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
- version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
+ version: 1, is_active: true, coverage_percent: 70, franchise_percent: 15,
annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
effective_to: null,
category_coverages: { outpatient: 70, inpatient: 30 },
@@ -27,11 +27,11 @@ const categories: ServiceCategoryOption[] = [
];
describe('buildInsurancePayload', () => {
- it('converts toman → rials, per-category percents, and Y-m-d → unix', () => {
+ it('keeps the franchise a percent, converts ceiling toman → rials and Y-m-d → unix', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
categoryPercents: { outpatient: '80', inpatient: '30' },
- franchise: '50000', ceiling: '2000000',
+ franchise: '10', ceiling: '2000000',
effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
});
expect(payload.insurance_id).toBe(3);
@@ -41,7 +41,7 @@ describe('buildInsurancePayload', () => {
{ key: 'outpatient', coverage_percent: 80 },
{ key: 'inpatient', coverage_percent: 30 },
]);
- expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
+ expect(payload.franchise_percent).toBe(10); // درصد است، نه مبلغ
expect(payload.annual_ceiling_rials).toBe(20_000_000);
expect(typeof payload.effective_from).toBe('number');
expect(payload.effective_to).toBeGreaterThan(payload.effective_from!);
@@ -58,10 +58,10 @@ describe('buildInsurancePayload', () => {
it('forces franchise to zero on a basic contract', () => {
const payload = buildInsurancePayload({
- ...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '50000',
+ ...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '10',
categoryPercents: { outpatient: '70' },
});
- expect(payload.franchise_rials).toBe(0);
+ expect(payload.franchise_percent).toBe(0);
});
it('omits category_coverages when the user may not override them', () => {
@@ -75,9 +75,9 @@ describe('buildInsurancePayload', () => {
});
describe('contractToForm', () => {
- 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');
+ it('maps the franchise percent, contract kind, and effective category percents', () => {
+ const form = contractToForm(mkContract({ franchise_percent: 30, kind: 'supplementary' }));
+ expect(form.franchise).toBe('30');
expect(form.kind).toBe('supplementary');
expect(form.categoryPercents).toEqual({ outpatient: '70', inpatient: '30' });
});
@@ -95,7 +95,7 @@ describe('InsuranceModal', () => {
expect(screen.getByText('پایه')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات بستری')).toBeInTheDocument();
- expect(screen.queryByText('فرانشیز (تومان)')).not.toBeInTheDocument();
+ expect(screen.queryByText('فرانشیز (درصد)')).not.toBeInTheDocument();
expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
});
@@ -105,7 +105,48 @@ describe('InsuranceModal', () => {
{}} onSubmit={() => {}} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
- expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
+ expect(screen.getByText('فرانشیز (درصد)')).toBeInTheDocument();
+ });
+
+ it('blocks submit until every rendered category has a valid percent', () => {
+ const onSubmit = vi.fn();
+ renderWithProviders(
+ {}}
+ onSubmit={onSubmit}
+ />,
+ );
+
+ // بستری بدون درصد مانده: ثبت باید بسته باشد و پیام الزامی دیده شود.
+ expect(screen.getByText('ثبت بیمه')).toBeDisabled();
+ expect(screen.getByText('درصد پوشش الزامی است (۱ تا ۱۰۰)')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('درصد پوشش خدمات بستری'), { target: { value: '30' } });
+ expect(screen.getByText('ثبت بیمه')).not.toBeDisabled();
+
+ fireEvent.click(screen.getByText('ثبت بیمه'));
+ expect(onSubmit).toHaveBeenCalled();
+ });
+
+ it('renders only the service kinds it is given', () => {
+ renderWithProviders(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
+ expect(screen.queryByText('درصد پوشش — خدمات بستری')).not.toBeInTheDocument();
});
it('prefills the percents of an edited contract and marks admin defaults', () => {
@@ -132,7 +173,7 @@ describe('InsuranceModal', () => {
);
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
- insurance_id: 3, coverage_percent: 70, franchise_rials: 0, kind: 'basic',
+ insurance_id: 3, coverage_percent: 70, franchise_percent: 0, kind: 'basic',
category_coverages: [
{ key: 'outpatient', coverage_percent: 70 },
{ key: 'inpatient', coverage_percent: 30 },
diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx
index 768f634b..e2af73a3 100644
--- a/assets/admin/components/InsuranceModal.tsx
+++ b/assets/admin/components/InsuranceModal.tsx
@@ -22,7 +22,8 @@ export interface Contract {
version: number;
is_active: boolean;
coverage_percent: number;
- franchise_rials: number;
+ /** درصد، نه مبلغ — سهم اجباری بیمار از مبلغ تحت پوشش تکمیلی. */
+ franchise_percent: number;
annual_ceiling_rials: number | null;
kind: string | null;
effective_from: number;
@@ -40,7 +41,7 @@ export interface InsuranceFormValues {
effectiveTo: string; // Y-m-d
/** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */
categoryPercents: Record;
- franchise: string; // toman
+ franchise: string; // percent
ceiling: string; // toman
}
@@ -64,7 +65,7 @@ export function contractToForm(c: Contract): InsuranceFormValues {
effectiveFrom: unixToIso(c.effective_from),
effectiveTo: unixToIso(c.effective_to),
categoryPercents: percentsToStrings(c.category_coverages),
- franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
+ franchise: c.franchise_percent != null ? String(c.franchise_percent) : '',
ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
};
}
@@ -73,6 +74,12 @@ function percentsToStrings(map?: Record): Record
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
}
+/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمیدهد. */
+export function isValidPercent(raw?: string): boolean {
+ const n = Number(raw);
+ return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
+}
+
/**
* 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
@@ -80,7 +87,7 @@ function percentsToStrings(map?: Record): Record
*
* `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. فرانشیز فقط در قرارداد تکمیلی معنا دارد.
+ * admin defaults. فرانشیز درصد است و فقط در قرارداد تکمیلی معنا دارد.
*/
export function buildInsurancePayload(
v: InsuranceFormValues,
@@ -95,7 +102,7 @@ export function buildInsurancePayload(
kind: v.kind || null,
// ستون قدیمی قرارداد؛ آخرین سطح fallback است و با درصد سرپایی همگام میماند.
coverage_percent: Number(v.categoryPercents.outpatient ?? percents[0]?.[1] ?? 0) || 0,
- franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
+ franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo),
@@ -111,7 +118,7 @@ interface Props {
editContract: Contract | null;
/** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
options: InsuranceOption[];
- /** انواع خدمت از سرور — یک ورودی درصد به ازای هر نوع رندر میشود. */
+ /** نوع خدمتهای *فعالِ* همین tenant — یک ورودی درصد به ازای هر نوع رندر میشود. */
categories: ServiceCategoryOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string;
@@ -155,8 +162,15 @@ export default function InsuranceModal({
const setPercent = (key: string, raw: string) =>
setForm((f) => ({ ...f, categoryPercents: { ...f.categoryPercents, [key]: digitsOnly(raw, 3) } }));
+ // بدون مجوز update درصدها اصلاً ارسال نمیشوند، پس اجبارشان هم بیمعناست.
+ const missingPercents = canUpdate
+ ? categories.filter((c) => !isValidPercent(form.categoryPercents[c.key])).map((c) => c.key)
+ : [];
+ const franchiseInvalid = form.kind === 'supplementary' && form.franchise !== '' && Number(form.franchise) > 100;
+ const canSubmit = !!form.insuranceId && missingPercents.length === 0 && !franchiseInvalid;
+
const submit = () => {
- if (!form.insuranceId) return;
+ if (!canSubmit) return;
onSubmit(buildInsurancePayload(form, doctorUuid, canUpdate));
};
@@ -177,7 +191,7 @@ export default function InsuranceModal({
{isPending ? '...' : 'ثبت بیمه'}
@@ -230,10 +244,12 @@ export default function InsuranceModal({
value={form.categoryPercents[c.key] ?? ''}
onChange={(e) => setPercent(c.key, e.target.value)}
/>
-
+
{!canUpdate
? 'مقدار پیشفرض تنظیمات مرکزی'
- : sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
+ : missingPercents.includes(c.key)
+ ? 'درصد پوشش الزامی است (۱ تا ۱۰۰)'
+ : sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
? 'پیشفرض ادمین'
: ' '}
@@ -244,8 +260,19 @@ export default function InsuranceModal({
{!isBasic && (
- فرانشیز (تومان)
- set({ franchise: digitsOnly(e.target.value) })} />
+ فرانشیز (درصد)
+ set({ franchise: digitsOnly(e.target.value, 3) })}
+ />
+
+ {franchiseInvalid ? 'فرانشیز نمیتواند بیش از ۱۰۰ باشد' : 'سهم بیمار از مبلغ تحت پوشش'}
+
)}
diff --git a/assets/admin/components/ServiceInsuranceModal.tsx b/assets/admin/components/ServiceInsuranceModal.tsx
index 35b4119a..9430a3a1 100644
--- a/assets/admin/components/ServiceInsuranceModal.tsx
+++ b/assets/admin/components/ServiceInsuranceModal.tsx
@@ -19,14 +19,14 @@ interface CoverageRow {
service_item_uuid: string | null;
covered: boolean;
coverage_percent: number | null;
- franchise_rials: number | null;
+ franchise_percent: number | null;
ceiling_rials: number | null;
}
interface Draft {
covered: boolean;
coverage_percent: number | null;
- franchise_rials: number | null;
+ franchise_percent: number | null;
ceiling_rials: number | null;
}
@@ -46,20 +46,22 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
const rows = (data as any)?.data?.data as CoverageRow[] | undefined;
const existing = rows?.find((r) => r.service_item_uuid === item.uuid);
- const [draft, setDraft] = useState({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
- // متن خام فیلد درصد جدا از مقدار عددی نگه داشته میشود تا کاربر بتواند فیلد را خالی کند.
+ const [draft, setDraft] = useState({ covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
+ // متن خام فیلدهای درصد جدا از مقدار عددی نگه داشته میشود تا کاربر بتواند فیلد را خالی کند.
const [percentText, setPercentText] = useState('');
+ const [franchiseText, setFranchiseText] = useState('');
useEffect(() => {
setDraft(existing
? {
covered: existing.covered,
coverage_percent: existing.coverage_percent,
- franchise_rials: existing.franchise_rials,
+ franchise_percent: existing.franchise_percent,
ceiling_rials: existing.ceiling_rials,
}
- : { covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
+ : { covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
setPercentText(existing?.coverage_percent == null ? '' : String(existing.coverage_percent));
+ setFranchiseText(existing?.franchise_percent == null ? '' : String(existing.franchise_percent));
}, [existing]);
const saveMut = useMutation({
@@ -68,7 +70,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
service_item_uuid: item.uuid,
covered: draft.covered,
coverage_percent: draft.coverage_percent,
- franchise_rials: draft.franchise_rials,
+ franchise_percent: draft.franchise_percent,
ceiling_rials: draft.ceiling_rials,
}),
onSuccess: () => {
@@ -144,14 +146,19 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
/>
diff --git a/assets/admin/components/TenantInsuranceContracts.test.tsx b/assets/admin/components/TenantInsuranceContracts.test.tsx
index e1e9883f..f995126a 100644
--- a/assets/admin/components/TenantInsuranceContracts.test.tsx
+++ b/assets/admin/components/TenantInsuranceContracts.test.tsx
@@ -17,7 +17,7 @@ const patch = api.patch as ReturnType
;
const mk = (over: Partial): Contract => ({
uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
- version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0,
+ version: 1, is_active: true, coverage_percent: 70, franchise_percent: 0,
annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over,
});
@@ -55,15 +55,15 @@ describe('contractSummary', () => {
});
it('shows the franchise only on a supplementary contract', () => {
- const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories);
+ const basic = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'basic' }), categories);
expect(basic).not.toContain('فرانشیز');
- const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories);
+ const supp = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'supplementary' }), categories);
expect(supp).toContain('فرانشیز');
});
it('marks an unlimited ceiling', () => {
- const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories);
+ const s = contractSummary(mk({ franchise_percent: 0, annual_ceiling_rials: null }), categories);
expect(s).toContain('سقف پوشش نامحدود');
});
diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx
index d233a2e3..989d79eb 100644
--- a/assets/admin/components/TenantInsuranceContracts.tsx
+++ b/assets/admin/components/TenantInsuranceContracts.tsx
@@ -46,8 +46,8 @@ export function contractSummary(c: Contract, categories: ServiceCategoryOption[]
.map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`);
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)}`);
+ if (c.insurance_kind === 'supplementary' && c.franchise_percent > 0) {
+ parts.push(`فرانشیز ${formatNumber(c.franchise_percent)}٪`);
}
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
@@ -112,6 +112,15 @@ export default function TenantInsuranceContracts() {
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? [];
const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? [];
+
+ // فرم قرارداد فقط نوع خدمتهایی را میگیرد که همین tenant بیمهایشان میکند؛
+ // لیست سراسری service-categories اینجا اشتباه است چون نوع خاموش را هم میآورد.
+ const enabledCategories: ServiceCategoryOption[] = useMemo(
+ () => ((pricingQuery.data as any)?.data?.service_categories ?? [])
+ .filter((c: { enabled: boolean }) => c.enabled)
+ .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })),
+ [pricingQuery.data],
+ );
const activeIds = new Set(contracts.map((c) => c.insurance_id));
// Add-mode options: only the active tab's kind, excluding already-contracted insurances.
const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id));
@@ -259,7 +268,7 @@ export default function TenantInsuranceContracts() {
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
- categories={categories}
+ categories={enabledCategories}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
canUpdate={canUpdate}
@@ -338,7 +347,7 @@ function ContractDetails({ contract: c, categories }: { contract: Contract; cate
/>
))}
{isSupplementary && (
- 0 ? formatRial(c.franchise_rials) : '—'} />
+ 0 ? `${formatNumber(c.franchise_percent)}٪` : '—'} />
)}
diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx
index 64d0f90d..d906b1d6 100644
--- a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx
+++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx
@@ -120,7 +120,7 @@ describe('ConfirmAppointmentModal', () => {
const CONTRACT = {
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
- is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
+ is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
diff --git a/assets/admin/components/appointments/TurnsTimeline.test.tsx b/assets/admin/components/appointments/TurnsTimeline.test.tsx
index 5684f85a..881b5126 100644
--- a/assets/admin/components/appointments/TurnsTimeline.test.tsx
+++ b/assets/admin/components/appointments/TurnsTimeline.test.tsx
@@ -75,7 +75,7 @@ describe('TurnsTimeline', () => {
url === '/api/v1/billing/tenant-insurances'
? Promise.resolve({ success: true, data: { data: [{
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
- coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
+ coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
}] } })
: Promise.resolve({ success: true, data: [] }),
);
diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx
index 07a94104..1e39b800 100644
--- a/assets/admin/components/session/CreateStep.test.tsx
+++ b/assets/admin/components/session/CreateStep.test.tsx
@@ -89,17 +89,23 @@ describe('patientShareOf — آینهی BillingCalculator', () => {
});
it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمیکند', () => {
- const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
+ const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50, ceiling: null }, null);
expect(share).toBe(0);
});
- it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه میشود', () => {
+ it('فرانشیز درصدیِ تکمیلی از سهم بیمه کم میشود', () => {
+ // پایه ۷۰٪ → ۴۲۰٬۰۰۰؛ باقیمانده ۱۸۰٬۰۰۰؛ تکمیلی ۱۰۰٪ منهای فرانشیز ۱۰٪ → ۱۶۲٬۰۰۰.
const share = patientShareOf(
600_000,
- { covered: true, percent: 70, franchise: 90_000, ceiling: null },
- { covered: true, percent: 100, franchise: 50_000, ceiling: null },
+ { covered: true, percent: 70, franchise: 50, ceiling: null },
+ { covered: true, percent: 100, franchise: 10, ceiling: null },
);
- expect(share).toBe(50_000);
+ expect(share).toBe(18_000);
+ });
+
+ it('فرانشیز بزرگتر از تعهد، سهم بیمه را صفر میکند نه منفی', () => {
+ const share = patientShareOf(600_000, null, { covered: true, percent: 20, franchise: 80, ceiling: null });
+ expect(share).toBe(600_000);
});
it('سقف تعهد سهم بیمه را محدود میکند', () => {
@@ -117,7 +123,7 @@ describe('patientShareOf — آینهی BillingCalculator', () => {
const BASIC_CONTRACT = {
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
- is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
+ is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx
index aa8274c4..d1020623 100644
--- a/assets/admin/components/session/CreateStep.tsx
+++ b/assets/admin/components/session/CreateStep.tsx
@@ -17,7 +17,7 @@ import {
} from '../../lib/insuranceShares';
interface Contract extends TenantContract { uuid: string }
-interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
+interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_percent: number | null; ceiling_rials: 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 }
@@ -208,7 +208,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
return {
covered: true,
percent: ov?.coverage_percent ?? contractPercentFor(contract, category),
- franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
+ franchise: isSupplementary ? (ov?.franchise_percent ?? contract.franchise_percent) : 0,
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
};
};
diff --git a/assets/admin/lib/insuranceShares.ts b/assets/admin/lib/insuranceShares.ts
index f21ddaea..e50b0dca 100644
--- a/assets/admin/lib/insuranceShares.ts
+++ b/assets/admin/lib/insuranceShares.ts
@@ -7,7 +7,7 @@
export interface CoverageRule {
covered: boolean;
percent: number;
- /** فقط در بیمهٔ تکمیلی معنا دارد. */
+ /** درصد سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در بیمهٔ تکمیلی معنا دارد. */
franchise: number;
ceiling: number | null;
}
@@ -19,7 +19,7 @@ export interface TenantContract {
insurance_kind: string | null;
is_active?: boolean;
coverage_percent: number;
- franchise_rials: number;
+ franchise_percent: number;
annual_ceiling_rials: number | null;
category_coverages?: Record;
}
@@ -28,8 +28,9 @@ export interface TenantContract {
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
/**
- * سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی (روی باقیمانده) + فرانشیزِ تکمیلی.
- * فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمیکند.
+ * سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی.
+ * فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر میشود،
+ * نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمیکند.
*/
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
let baseShare = 0;
@@ -41,12 +42,14 @@ export function patientShareOf(total: number, base: CoverageRule | null, supp: C
}
let suppShare = 0;
if (supp && supp.covered) {
- suppShare = Math.round(remaining * (supp.percent / 100));
+ suppShare = Math.max(
+ 0,
+ Math.round(remaining * (supp.percent / 100)) - Math.round(remaining * (supp.franchise / 100)),
+ );
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
- remaining = remaining - suppShare;
}
- return Math.min(remaining + (supp?.franchise ?? 0), total);
+ return total - baseShare - suppShare;
}
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
@@ -61,7 +64,7 @@ export function ruleOf(contract: TenantContract | null, category: string): Cover
return {
covered: true,
percent: contractPercentFor(contract, category),
- franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_rials : 0,
+ franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_percent : 0,
ceiling: contract.annual_ceiling_rials,
};
}
diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx
index 2ec6d809..4a8e7719 100644
--- a/assets/admin/pages/AppointmentEditPage.test.tsx
+++ b/assets/admin/pages/AppointmentEditPage.test.tsx
@@ -70,7 +70,7 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
const CONTRACT = {
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
- coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
+ coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx
index abc6b3d4..df67dd23 100644
--- a/assets/admin/pages/ClaimsPage.tsx
+++ b/assets/admin/pages/ClaimsPage.tsx
@@ -32,6 +32,15 @@ const PAYMENT_OPTIONS = [
{ value: 'unpaid', label: 'وصولنشده' },
];
+/** واژگان مطالبه `base` است، نه `basic` که در قرارداد بیمه به کار میرود. */
+const KIND_OPTIONS = [
+ { value: '', label: 'همه انواع' },
+ { value: 'base', label: 'پایه' },
+ { value: 'supplementary', label: 'تکمیلی' },
+];
+
+const KIND_LABEL: Record = { base: 'پایه', supplementary: 'تکمیلی' };
+
const isoNDaysAgo = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() - days);
@@ -69,6 +78,7 @@ export default function ClaimsPage() {
const search = params.get('search') ?? '';
const status = params.get('status') ?? '';
const insuranceId = params.get('insurance_id') ?? '';
+ const kind = params.get('kind') ?? '';
const paymentStatus = params.get('payment_status') ?? '';
const from = params.get('from') ?? '';
const to = params.get('to') ?? '';
@@ -76,7 +86,7 @@ export default function ClaimsPage() {
const sort = params.get('sort') ?? 'last_activity_at';
const dir = (params.get('dir') ?? 'desc') as 'asc' | 'desc';
- const hasFilters = !!(search || status || insuranceId || paymentStatus || from || to);
+ const hasFilters = !!(search || status || insuranceId || kind || paymentStatus || from || to);
/** تغییر فیلتر همیشه به صفحهی اول برمیگردد؛ ماندن روی صفحه ۵ با نتیجهی جدید بیمعناست. */
const setParam = (patch: Record) => {
@@ -95,13 +105,14 @@ export default function ClaimsPage() {
if (search) qs.set('search', search);
if (status) qs.set('status', status);
if (insuranceId) qs.set('insurance_id', insuranceId);
+ if (kind) qs.set('kind', kind);
if (paymentStatus) qs.set('payment_status', paymentStatus);
const fromUnix = toUnix(from);
const toUnixVal = endOfDayUnix(to);
if (fromUnix) qs.set('from', String(fromUnix));
if (toUnixVal) qs.set('to', String(toUnixVal));
return qs.toString();
- }, [page, sort, dir, search, status, insuranceId, paymentStatus, from, to]);
+ }, [page, sort, dir, search, status, insuranceId, kind, paymentStatus, from, to]);
const listQuery = useQuery>({
queryKey: ['claims-by-patient', queryString],
@@ -142,6 +153,25 @@ export default function ClaimsPage() {
{ key: 'full_name', header: 'بیمار', sortable: true, render: (r) => r.full_name ?? '—' },
{ key: 'mobile', header: 'موبایل', render: (r) => r.mobile ?? '—' },
{ key: 'national_code', header: 'کد ملی', render: (r) => r.national_code ?? '—' },
+ {
+ key: 'insurances',
+ header: 'بیمه',
+ render: (r) => (
+ (r.insurances ?? []).length === 0 ? '—' : (
+
+ {r.insurances.map((i) => (
+
+ {i.insurance_name ?? `بیمه #${i.insurance_id}`}
+
+ ))}
+
+ )
+ ),
+ },
{ key: 'claims_count', header: 'تعداد درخواست', sortable: true, render: (r) => formatNumber(r.claims_count) },
{ key: 'total_services_rials', header: 'مجموع خدمات', sortable: true, render: (r) => formatRial(r.total_services_rials) },
{ key: 'total_insurance_rials', header: 'سهم بیمه', sortable: true, render: (r) => formatRial(r.total_insurance_rials) },
@@ -176,6 +206,16 @@ export default function ClaimsPage() {
height={38}
/>
+
+ نوع بیمه
+ setParam({ kind: v ? String(v) : '' })}
+ placeholder="همه انواع"
+ height={38}
+ />
+
وضعیت
setParam({ search: v })}
- searchPlaceholder="نام، موبایل یا کد ملی بیمار"
+ searchPlaceholder="نام، موبایل، کد ملی بیمار یا نام بیمه"
emptyMessage="پرونده بیمهای ثبت نشده است."
sortKey={sort}
sortDir={dir}
diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx
index 2dcc28dd..9a293473 100644
--- a/assets/admin/pages/ServiceDetailPage.tsx
+++ b/assets/admin/pages/ServiceDetailPage.tsx
@@ -43,7 +43,7 @@ interface CoverageRow {
service_item_uuid: string | null;
covered: boolean;
coverage_percent: number | null;
- franchise_rials: number | null;
+ franchise_percent: number | null;
ceiling_rials: number | null;
}
@@ -278,7 +278,7 @@ function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance
) : (
پوشش {formatNumber(percent)}٪
- {row?.franchise_rials ? فرانشیز {formatRial(row.franchise_rials)} : null}
+ {row?.franchise_percent ? فرانشیز {formatNumber(row.franchise_percent)}٪ : null}
{row?.ceiling_rials ? سقف {formatRial(row.ceiling_rials)} : null}
{!row && (ارث از قرارداد) }
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts
index c2b3f3e3..fb574c12 100644
--- a/assets/admin/types/index.ts
+++ b/assets/admin/types/index.ts
@@ -229,9 +229,17 @@ export interface ClaimPatientRow {
total_approved_rials: number;
total_paid_rials: number;
overall_status: ClaimStatus;
+ /** بیمههایی که این بیمار زیرشان مطالبه دارد — واژگان مطالبه: base | supplementary. */
+ insurances: ClaimInsuranceRef[];
last_activity_at: number;
}
+export interface ClaimInsuranceRef {
+ insurance_id: number;
+ insurance_name: string | null;
+ kind: string | null;
+}
+
export interface ClaimStatusLogEntry {
uuid: string;
from_status: string | null;
diff --git a/migrations/Version20260728180000.php b/migrations/Version20260728180000.php
new file mode 100644
index 00000000..c3a55e0c
--- /dev/null
+++ b/migrations/Version20260728180000.php
@@ -0,0 +1,44 @@
+addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent NUMERIC(5, 2) NOT NULL');
+ $this->addSql('UPDATE tenant_insurances SET franchise_percent = 0');
+
+ $this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent NUMERIC(5, 2) DEFAULT NULL');
+ $this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // The rial amounts are gone for good; down() restores the shape, not the data.
+ $this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_percent franchise_rials INT DEFAULT 0 NOT NULL');
+ $this->addSql('UPDATE tenant_insurances SET franchise_rials = 0');
+
+ $this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_percent franchise_rials INT DEFAULT NULL');
+ $this->addSql('UPDATE tenant_service_coverage SET franchise_rials = NULL');
+ }
+}
diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php
index b819ecd2..912f25e3 100644
--- a/src/Billing/Controller/BillingController.php
+++ b/src/Billing/Controller/BillingController.php
@@ -330,6 +330,10 @@ class BillingController extends BaseController
}
$filters = $this->claimFilters($request);
+ if ($filters['kind'] !== null && !in_array($filters['kind'], [Claim::KIND_BASE, Claim::KIND_SUPPLEMENTARY], true)) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع بیمه نامعتبر است', 422, 'kind');
+ }
+
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$sort = (string) $request->query->get('sort', 'last_activity_at');
@@ -423,6 +427,7 @@ class BillingController extends BaseController
return [
'status' => $request->query->get('status') ?: null,
'insurance_id' => $request->query->get('insurance_id') ?: null,
+ 'kind' => $request->query->get('kind') ?: null,
'doctor_id' => $request->query->get('doctor_id') ?: null,
'payment_status' => $request->query->get('payment_status') ?: null,
'from' => $request->query->get('from') ?: null,
diff --git a/src/Billing/Repository/ClaimRepository.php b/src/Billing/Repository/ClaimRepository.php
index a46799d0..19c5e2c9 100644
--- a/src/Billing/Repository/ClaimRepository.php
+++ b/src/Billing/Repository/ClaimRepository.php
@@ -169,11 +169,41 @@ class ClaimRepository extends ServiceEntityRepository
'total_approved_rials' => (int) $r['total_approved_rials'],
'total_paid_rials' => (int) $r['total_paid_rials'],
'overall_status' => count($statuses) === 1 ? reset($statuses) : 'mixed',
+ 'insurances' => self::parseInsurances((string) ($r['insurances'] ?? '')),
'last_activity_at' => (int) $r['last_activity_at'],
];
}, $rows);
}
+ /**
+ * ردیفهای GROUP_CONCAT بیمههای یک بیمار → آرایهٔ ساختیافته. یک بیمار میتواند
+ * مطالبه زیر چند بیمه داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار.
+ *
+ * @return list
+ */
+ private static function parseInsurances(string $concatenated): array
+ {
+ if ($concatenated === '') {
+ return [];
+ }
+
+ $rows = [];
+ foreach (explode('~', $concatenated) as $chunk) {
+ [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null);
+ if ($id === null || $id === '') {
+ continue;
+ }
+
+ $rows[] = [
+ 'insurance_id' => (int) $id,
+ 'insurance_name' => $name === '' ? null : $name,
+ 'kind' => $kind === '' ? null : $kind,
+ ];
+ }
+
+ return $rows;
+ }
+
public function countPatientsWithClaims(string $entityType, int $entityId, array $filters): int
{
[$where, $params] = $this->patientAggregateFilters($filters);
@@ -222,12 +252,14 @@ class ClaimRepository extends ServiceEntityRepository
WHERE i3.id IN (SELECT DISTINCT cm3.invoice_id FROM claim_map cm3 WHERE cm3.record_id = pr.id)
), 0) AS total_patient_rials,
GROUP_CONCAT(DISTINCT c.status) AS statuses,
+ GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, COALESCE(ins.name, ''), c.insurance_kind) SEPARATOR '~') AS insurances,
MAX(c.updated_at) AS last_activity_at
FROM claim_map cm
JOIN claims c ON c.id = cm.claim_id
JOIN invoices inv ON inv.id = cm.invoice_id
JOIN patient_records pr ON pr.id = cm.record_id
JOIN users u ON u.id = pr.user_id
+ LEFT JOIN insurances ins ON ins.id = c.insurance_id
{$where}
GROUP BY pr.id, u.uuid, pr.uuid, u.real_name, u.mobile_number, u.national_code
SQL;
@@ -250,6 +282,10 @@ class ClaimRepository extends ServiceEntityRepository
$conditions[] = 'c.insurance_id = :insId';
$params['insId'] = (int) $filters['insurance_id'];
}
+ if (!empty($filters['kind'])) {
+ $conditions[] = 'c.insurance_kind = :kind';
+ $params['kind'] = (string) $filters['kind'];
+ }
if (!empty($filters['from'])) {
$conditions[] = 'c.created_at >= :from';
$params['from'] = (int) $filters['from'];
@@ -270,7 +306,10 @@ class ClaimRepository extends ServiceEntityRepository
: 'c.status <> \'paid\'';
}
if (!empty($filters['search'])) {
- $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
+ // نام بیمه هم جستجو میشود: کاربر «آسیا» را مینویسد و انتظار دارد بیماران
+ // همان بیمه بیایند، نه فقط بیماری که اسمش آسیاست.
+ $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search'
+ . ' OR u.national_code LIKE :search OR ins.name LIKE :search)';
$params['search'] = '%' . trim((string) $filters['search']) . '%';
}
@@ -328,6 +367,7 @@ class ClaimRepository extends ServiceEntityRepository
LEFT JOIN patient_sessions ps ON ps.id = inv.patient_session_id
LEFT JOIN appointments a ON a.id = ps.appointment_id
LEFT JOIN doctors d ON d.id = a.doctor_id
+ LEFT JOIN insurances ins ON ins.id = c.insurance_id
{$where}
ORDER BY c.created_at DESC, c.id DESC
SQL;
diff --git a/src/Billing/Service/BillingCalculator.php b/src/Billing/Service/BillingCalculator.php
index 23a37fb1..b582cee1 100644
--- a/src/Billing/Service/BillingCalculator.php
+++ b/src/Billing/Service/BillingCalculator.php
@@ -10,7 +10,7 @@ class BillingCalculator
{
/**
* محاسبهی سهم برای یک آیتم.
- * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز تکمیلی.
+ * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → تعهد مکمل روی باقیمانده منهای فرانشیز (با سقف).
*/
public function calculateItem(
Money $total,
@@ -30,17 +30,19 @@ class BillingCalculator
$suppShare = Money::zero();
if ($supplementary !== null && $supplementary->covered) {
- $suppShare = $remaining->percent($supplementary->coveragePercent);
+ // فرانشیز سهم اجباری بیمار از همین مبلغ است و از تعهد تکمیلی کسر میشود —
+ // نه اینکه روی سهم بیمار سوار شود، وگرنه جمع سهمها از کل بیشتر میشد و
+ // مطالبهٔ ارسالی به بیمه بیش از سهم واقعیاش میبود.
+ $suppShare = $remaining->percent($supplementary->coveragePercent)
+ ->sub($remaining->percent($supplementary->franchisePercent));
+
if ($supplementary->ceilingRials !== null) {
$suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
}
- $remaining = $remaining->sub($suppShare);
}
- // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ
- // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمیکند.
- $franchise = new Money($supplementary?->franchiseRials ?? 0);
- $patient = $remaining->add($franchise)->min($total);
+ // بیمهٔ پایه صرفاً درصدی است و فرانشیزش در محاسبه دخالت نمیکند.
+ $patient = $total->sub($baseShare)->sub($suppShare);
return new ShareBreakdown(
totalRials: $total->rials,
diff --git a/src/Insurance/Command/SeedInsuranceScenarioCommand.php b/src/Insurance/Command/SeedInsuranceScenarioCommand.php
new file mode 100644
index 00000000..e6d53a7a
--- /dev/null
+++ b/src/Insurance/Command/SeedInsuranceScenarioCommand.php
@@ -0,0 +1,326 @@
+ 'تامین اجتماعی', 'kind' => 'basic', 'outpatient' => 30.0, 'inpatient' => 40.0, 'franchise' => 0.0, 'ceiling' => null],
+ ['name' => 'بیمه ایران', 'kind' => 'supplementary', 'outpatient' => 90.0, 'inpatient' => 80.0, 'franchise' => 10.0, 'ceiling' => 50_000_000],
+ ['name' => 'بیمه آسیا', 'kind' => 'supplementary', 'outpatient' => 70.0, 'inpatient' => 60.0, 'franchise' => 20.0, 'ceiling' => null],
+ ['name' => 'بیمه دی', 'kind' => 'supplementary', 'outpatient' => 100.0, 'inpatient' => 50.0, 'franchise' => 0.0, 'ceiling' => 10_000_000],
+ ];
+
+ /**
+ * بیمارهای سناریو. `base`/`supp` نام بیمهاند، `visit` قیمت ویزیت به ریال و
+ * `claim` وضعیت نهاییِ مطالبهها.
+ */
+ private const PATIENTS = [
+ ['name' => 'زهرا رضایی', 'base' => 'تامین اجتماعی', 'supp' => null, 'visit' => 5_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_PENDING],
+ ['name' => 'علی محمدی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه ایران', 'visit' => 8_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_SUBMITTED],
+ ['name' => 'مریم کاظمی', 'base' => null, 'supp' => 'بیمه آسیا', 'visit' => 6_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_APPROVED],
+ ['name' => 'حسین نوروزی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه دی', 'visit' => 40_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_PAID],
+ ];
+
+ public function __construct(
+ private readonly EntityManagerInterface $em,
+ private readonly DoctorRepository $doctorRepo,
+ private readonly InsuranceRepository $insuranceRepo,
+ private readonly TenantInsuranceService $tenantInsuranceService,
+ private readonly TenantServiceCategoryService $serviceCategories,
+ private readonly InvoiceService $invoiceService,
+ private readonly ClaimService $claimService,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption('doctor-mobile', null, InputOption::VALUE_REQUIRED, 'موبایل کاربرِ پزشک', '09389388131')
+ ->addOption('purge', null, InputOption::VALUE_NONE, 'فقط پاکسازی دادهی سناریو، بدون ساخت دوباره');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $mobile = (string) $input->getOption('doctor-mobile');
+
+ $doctor = $this->doctorRepo->createQueryBuilder('d')
+ ->join('d.user', 'u')
+ ->andWhere('u.mobileNumber = :mobile')
+ ->setParameter('mobile', $mobile)
+ ->getQuery()
+ ->getOneOrNullResult();
+
+ if ($doctor === null) {
+ $io->error(sprintf('پزشکی با موبایل %s یافت نشد', $mobile));
+ return Command::FAILURE;
+ }
+
+ $entityType = TenantInsurance::TYPE_DOCTOR;
+ $entityId = (int) $doctor->getId();
+ $io->title(sprintf('سناریوی بیمه — %s (doctor #%d)', $doctor->getName(), $entityId));
+
+ // پاکسازی همیشه انجام میشود تا اجرای دوباره دادهی تکراری نسازد؛ marker موبایل
+ // مخصوص همین سناریوست و به دادهی واقعی نمیرسد.
+ $purged = $this->purge($entityType, $entityId);
+ $io->text(sprintf('پاکسازی: %d بیمار سناریو حذف شد', $purged));
+
+ if ($input->getOption('purge')) {
+ $io->success('فقط پاکسازی انجام شد.');
+ return Command::SUCCESS;
+ }
+
+ $this->serviceCategories->save($entityType, $entityId, [
+ ['key' => 'outpatient', 'enabled' => true],
+ ['key' => 'inpatient', 'enabled' => true],
+ ]);
+
+ $contracts = $this->seedContracts($entityType, $entityId, $io);
+ if ($contracts === []) {
+ return Command::FAILURE;
+ }
+
+ $rows = [];
+ foreach (self::PATIENTS as $i => $spec) {
+ $rows[] = $this->seedPatient($entityType, $entityId, $i, $spec);
+ }
+
+ $io->section('قراردادهای بیمه');
+ $io->table(
+ ['بیمه', 'نوع', 'سرپایی', 'بستری', 'فرانشیز', 'سقف سالانه'],
+ array_map(static fn(array $c) => [
+ $c['name'],
+ $c['kind'] === 'basic' ? 'پایه' : 'تکمیلی',
+ $c['outpatient'] . '٪',
+ $c['inpatient'] . '٪',
+ $c['franchise'] . '٪',
+ $c['ceiling'] === null ? 'نامحدود' : number_format($c['ceiling']),
+ ], self::CONTRACTS),
+ );
+
+ $io->section('بیماران و مطالبات');
+ $io->table(['بیمار', 'پایه', 'تکمیلی', 'کل', 'سهم بیمه', 'سهم بیمار', 'وضعیت'], $rows);
+ $io->success('سناریو ساخته شد. ورود به پنل با ' . $mobile);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @return array کلید = نام بیمه
+ */
+ private function seedContracts(string $entityType, int $entityId, SymfonyStyle $io): array
+ {
+ $contracts = [];
+ foreach (self::CONTRACTS as $spec) {
+ $insurance = $this->insuranceRepo->findOneBy(['name' => $spec['name']]);
+ if ($insurance === null) {
+ $io->error(sprintf('بیمهٔ «%s» در کاتالوگ نیست؛ اول کاتالوگ بیمه را seed کنید', $spec['name']));
+ return [];
+ }
+
+ $contract = $this->tenantInsuranceService->activate(
+ $entityType,
+ $entityId,
+ (int) $insurance->getId(),
+ $spec['outpatient'],
+ $spec['franchise'],
+ $spec['ceiling'],
+ null,
+ null,
+ $spec['kind'],
+ );
+
+ $this->tenantInsuranceService->setCategoryCoverages($contract, [
+ ['key' => 'outpatient', 'coverage_percent' => $spec['outpatient']],
+ ['key' => 'inpatient', 'coverage_percent' => $spec['inpatient']],
+ ]);
+
+ $contracts[$spec['name']] = $contract;
+ }
+
+ return $contracts;
+ }
+
+ /**
+ * @param array{name: string, base: ?string, supp: ?string, visit: int, category: string, claim: string} $spec
+ * @return list ردیف جدول خلاصه
+ */
+ private function seedPatient(string $entityType, int $entityId, int $index, array $spec): array
+ {
+ $mobile = self::PATIENT_MOBILE_PREFIX . str_pad((string) ($index + 1), 3, '0', STR_PAD_LEFT);
+
+ $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]) ?? new User($mobile);
+ $user->setRealName($spec['name'])->setRoles(['ROLE_USER'])->setStatus(1);
+ $this->em->persist($user);
+ $this->em->flush();
+
+ $record = new PatientRecord($entityType, $entityId, $user, $entityType, $entityId);
+ $this->em->persist($record);
+ $this->em->flush();
+
+ $session = new PatientSession($record);
+ $session->setVisitPriceRials($spec['visit'])
+ ->setInsuranceBaseId($this->insuranceIdOf($spec['base']))
+ ->setInsuranceSupplementaryId($this->insuranceIdOf($spec['supp']))
+ ->setInsuranceServiceCategory(ServiceCategory::from($spec['category']))
+ ->setSessionAt(time());
+ $this->em->persist($session);
+ $this->em->flush();
+
+ $invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
+ $this->invoiceService->finalize($invoice);
+
+ $claims = $this->claimService->createFromInvoice($invoice);
+ foreach ($claims as $claim) {
+ $this->advanceClaim($claim, $spec['claim']);
+ }
+
+ $insuranceShare = $invoice->getTotalRials() - $invoice->getPatientRials();
+
+ return [
+ $spec['name'],
+ $spec['base'] ?? '—',
+ $spec['supp'] ?? '—',
+ number_format($invoice->getTotalRials()),
+ number_format($insuranceShare),
+ number_format($invoice->getPatientRials()),
+ $spec['claim'],
+ ];
+ }
+
+ /** مطالبه را تا وضعیت هدف جلو میبرد؛ هر گام از همان transition واقعی رد میشود. */
+ private function advanceClaim(Claim $claim, string $target): void
+ {
+ $path = match ($target) {
+ Claim::STATUS_SUBMITTED => [Claim::STATUS_SUBMITTED],
+ Claim::STATUS_APPROVED => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED],
+ Claim::STATUS_PAID => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED, Claim::STATUS_PAID],
+ Claim::STATUS_REJECTED => [Claim::STATUS_SUBMITTED, Claim::STATUS_REJECTED],
+ default => [],
+ };
+
+ foreach ($path as $step) {
+ $this->claimService->transition($claim, $step, [
+ 'approved_rials' => $step === Claim::STATUS_APPROVED ? $claim->getTotalClaimedRials() : null,
+ 'paid_rials' => $step === Claim::STATUS_PAID ? $claim->getTotalApprovedRials() : null,
+ 'reason' => $step === Claim::STATUS_REJECTED ? 'مدارک ناقص است' : '',
+ 'tracking_number' => $step === Claim::STATUS_SUBMITTED ? 'SC-' . $claim->getId() : null,
+ ]);
+ }
+ }
+
+ private function insuranceIdOf(?string $name): ?int
+ {
+ if ($name === null) {
+ return null;
+ }
+
+ return $this->insuranceRepo->findOneBy(['name' => $name])?->getId();
+ }
+
+ /** حذف بیماران سناریو و هرچه از آنها آویزان است. قراردادها با activate بازنویسی میشوند. */
+ private function purge(string $entityType, int $entityId): int
+ {
+ $conn = $this->em->getConnection();
+ $ids = $conn->executeQuery(
+ 'SELECT id FROM users WHERE mobile_number LIKE :prefix',
+ ['prefix' => self::PATIENT_MOBILE_PREFIX . '%'],
+ )->fetchFirstColumn();
+
+ if ($ids === []) {
+ return 0;
+ }
+
+ $params = ['ids' => $ids, 'type' => $entityType, 'entity' => $entityId];
+ $types = ['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER];
+
+ $conn->executeStatement(
+ 'DELETE ci FROM claim_items ci
+ JOIN invoice_items ii ON ii.id = ci.invoice_item_id
+ JOIN invoices inv ON inv.id = ii.invoice_id
+ JOIN patient_records pr ON pr.id = inv.patient_record_id
+ WHERE pr.user_id IN (:ids) AND pr.entity_type = :type AND pr.entity_id = :entity',
+ $params,
+ $types,
+ );
+ $conn->executeStatement(
+ 'DELETE csl FROM claim_status_logs csl
+ JOIN claims c ON c.id = csl.claim_id
+ LEFT JOIN claim_items ci ON ci.claim_id = c.id
+ WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity',
+ ['type' => $entityType, 'entity' => $entityId],
+ );
+ $conn->executeStatement(
+ 'DELETE c FROM claims c
+ LEFT JOIN claim_items ci ON ci.claim_id = c.id
+ WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity',
+ ['type' => $entityType, 'entity' => $entityId],
+ );
+ $conn->executeStatement(
+ 'DELETE ii FROM invoice_items ii
+ JOIN invoices inv ON inv.id = ii.invoice_id
+ JOIN patient_records pr ON pr.id = inv.patient_record_id
+ WHERE pr.user_id IN (:ids)',
+ $params,
+ $types,
+ );
+ $conn->executeStatement(
+ 'DELETE inv FROM invoices inv
+ JOIN patient_records pr ON pr.id = inv.patient_record_id
+ WHERE pr.user_id IN (:ids)',
+ $params,
+ $types,
+ );
+ $conn->executeStatement(
+ 'DELETE ps FROM patient_sessions ps
+ JOIN patient_records pr ON pr.id = ps.record_id
+ WHERE pr.user_id IN (:ids)',
+ $params,
+ $types,
+ );
+ $conn->executeStatement('DELETE FROM patient_records WHERE user_id IN (:ids)', $params, $types);
+ $conn->executeStatement('DELETE FROM users WHERE id IN (:ids)', $params, $types);
+
+ return count($ids);
+ }
+}
diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php
index da3e7c93..e446c7b3 100644
--- a/src/Insurance/Controller/InsuranceController.php
+++ b/src/Insurance/Controller/InsuranceController.php
@@ -499,7 +499,7 @@ class InsuranceController extends BaseController
$entityId,
$insuranceId,
(float) ($data['coverage_percent'] ?? 0),
- (int) ($data['franchise_rials'] ?? 0),
+ (float) ($data['franchise_percent'] ?? 0),
isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null
? (int) $data['annual_ceiling_rials'] : null,
isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null,
@@ -561,8 +561,12 @@ class InsuranceController extends BaseController
if (array_key_exists('coverage_percent', $data)) {
$contract->setCoveragePercent((float) $data['coverage_percent']);
}
- if (array_key_exists('franchise_rials', $data)) {
- $contract->setFranchiseRials((int) $data['franchise_rials']);
+ if (array_key_exists('franchise_percent', $data)) {
+ $percent = (float) $data['franchise_percent'];
+ if ($percent < 0 || $percent > 100) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent');
+ }
+ $contract->setFranchisePercent($percent);
}
if (array_key_exists('annual_ceiling_rials', $data)) {
$contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null);
@@ -682,7 +686,7 @@ class InsuranceController extends BaseController
$serviceItemId,
(bool) ($data['covered'] ?? true),
isset($data['coverage_percent']) && $data['coverage_percent'] !== null ? (float) $data['coverage_percent'] : null,
- isset($data['franchise_rials']) && $data['franchise_rials'] !== null ? (int) $data['franchise_rials'] : null,
+ isset($data['franchise_percent']) && $data['franchise_percent'] !== null ? (float) $data['franchise_percent'] : null,
isset($data['ceiling_rials']) && $data['ceiling_rials'] !== null ? (int) $data['ceiling_rials'] : null,
);
diff --git a/src/Insurance/Entity/TenantInsurance.php b/src/Insurance/Entity/TenantInsurance.php
index d63eaae5..efa1a1f7 100644
--- a/src/Insurance/Entity/TenantInsurance.php
+++ b/src/Insurance/Entity/TenantInsurance.php
@@ -41,8 +41,9 @@ class TenantInsurance
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
- #[ORM\Column(name: 'franchise_rials', type: 'integer')]
- private int $franchiseRials = 0;
+ /** درصدِ سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد. */
+ #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)]
+ private string $franchisePercent = '0.00';
#[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)]
private ?int $annualCeilingRials = null;
@@ -83,7 +84,7 @@ class TenantInsurance
public function getVersion(): int { return $this->version; }
public function isActive(): bool { return $this->isActive; }
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
- public function getFranchiseRials(): int { return $this->franchiseRials; }
+ public function getFranchisePercent(): float { return (float) $this->franchisePercent; }
public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; }
public function getKind(): ?string { return $this->kind; }
public function getEffectiveFrom(): int { return $this->effectiveFrom; }
@@ -91,7 +92,7 @@ class TenantInsurance
public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; }
- public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
+ public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; }
public function setKind(?string $v): self { $this->kind = $v; $this->updatedAt = time(); return $this; }
public function setEffectiveFrom(int $v): self { $this->effectiveFrom = $v; $this->updatedAt = time(); return $this; }
@@ -107,7 +108,7 @@ class TenantInsurance
'version' => $this->version,
'is_active' => $this->isActive,
'coverage_percent' => (float) $this->coveragePercent,
- 'franchise_rials' => $this->franchiseRials,
+ 'franchise_percent' => (float) $this->franchisePercent,
'annual_ceiling_rials' => $this->annualCeilingRials,
'kind' => $this->kind,
'effective_from' => $this->effectiveFrom,
diff --git a/src/Insurance/Entity/TenantServiceCoverage.php b/src/Insurance/Entity/TenantServiceCoverage.php
index 0b0b5d51..dc321fec 100644
--- a/src/Insurance/Entity/TenantServiceCoverage.php
+++ b/src/Insurance/Entity/TenantServiceCoverage.php
@@ -32,8 +32,9 @@ class TenantServiceCoverage
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
private ?string $coveragePercent = null;
- #[ORM\Column(name: 'franchise_rials', type: 'integer', nullable: true)]
- private ?int $franchiseRials = null;
+ /** null = ارث از قرارداد؛ درصد است، نه مبلغ. */
+ #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
+ private ?string $franchisePercent = null;
#[ORM\Column(name: 'ceiling_rials', type: 'integer', nullable: true)]
private ?int $ceilingRials = null;
@@ -55,12 +56,12 @@ class TenantServiceCoverage
public function getServiceItemId(): int { return $this->serviceItemId; }
public function isCovered(): bool { return $this->covered; }
public function getCoveragePercent(): ?float { return $this->coveragePercent !== null ? (float) $this->coveragePercent : null; }
- public function getFranchiseRials(): ?int { return $this->franchiseRials; }
+ public function getFranchisePercent(): ?float { return $this->franchisePercent !== null ? (float) $this->franchisePercent : null; }
public function getCeilingRials(): ?int { return $this->ceilingRials; }
public function setCovered(bool $v): self { $this->covered = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(?float $v): self { $this->coveragePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
- public function setFranchiseRials(?int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
+ public function setFranchisePercent(?float $v): self { $this->franchisePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
public function setCeilingRials(?int $v): self { $this->ceilingRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
@@ -71,7 +72,7 @@ class TenantServiceCoverage
'service_item_id' => $this->serviceItemId,
'covered' => $this->covered,
'coverage_percent' => $this->getCoveragePercent(),
- 'franchise_rials' => $this->franchiseRials,
+ 'franchise_percent' => $this->getFranchisePercent(),
'ceiling_rials' => $this->ceilingRials,
];
}
diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php
index 804fd225..4afe08d5 100644
--- a/src/Insurance/Service/TenantInsuranceService.php
+++ b/src/Insurance/Service/TenantInsuranceService.php
@@ -30,6 +30,7 @@ class TenantInsuranceService
private readonly ServiceItemRepository $serviceItemRepo,
private readonly TenantInsuranceCategoryCoverageRepository $categoryCoverageRepo,
private readonly InsuranceCoverageDefaultService $coverageDefaults,
+ private readonly TenantServiceCategoryService $serviceCategories,
) {}
/**
@@ -41,7 +42,7 @@ class TenantInsuranceService
int $entityId,
int $insuranceId,
float $coveragePercent,
- int $franchiseRials = 0,
+ float $franchisePercent = 0.0,
?int $annualCeilingRials = null,
?int $effectiveFrom = null,
?int $effectiveTo = null,
@@ -58,8 +59,10 @@ class TenantInsuranceService
$contract = new TenantInsurance($entityType, $entityId, $insuranceId, $version);
}
+ $this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent');
+
$contract->setCoveragePercent($coveragePercent)
- ->setFranchiseRials($franchiseRials)
+ ->setFranchisePercent($franchisePercent)
->setAnnualCeilingRials($annualCeilingRials)
// kind defaults to the catalog type; caller may override to categorise the contract.
->setKind($kind ?? $insurance->getType()->value)
@@ -85,13 +88,18 @@ class TenantInsuranceService
/**
* Replaces the contract's category overrides. A row whose percentage is null is
- * dropped, which hands that category back to the central admin default.
+ * dropped, which hands that category back to the central admin default — but only
+ * for a service kind the tenant does not cover; an enabled kind must carry a
+ * percentage, otherwise the contract would silently bill it at zero.
*
* @param list $rows
- * @throws AppException on an unknown category or an out-of-range percentage
+ * @throws AppException on an unknown category, an out-of-range percentage, or a
+ * priced-out enabled service kind
*/
public function setCategoryCoverages(TenantInsurance $contract, array $rows): void
{
+ $this->assertEnabledCategoriesArePriced($contract, $rows);
+
foreach ($rows as $row) {
$category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null);
if ($category === null) {
@@ -113,9 +121,7 @@ class TenantInsuranceService
}
$percent = (float) $raw;
- if ($percent < 0 || $percent > 100) {
- throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
- }
+ $this->assertPercentInRange($percent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد');
$entity = $existing ?? new TenantInsuranceCategoryCoverage($contract->getId(), $category);
$this->categoryCoverageRepo->save($entity->setCoveragePercent($percent), false);
@@ -124,6 +130,58 @@ class TenantInsuranceService
$this->categoryCoverageRepo->flush();
}
+ /**
+ * هر نوع خدمتی که tenant آن را بیمهای کرده باید در پایانِ این ذخیرهسازی درصد
+ * پوشش مؤثر داشته باشد — از خودِ payload، از override قبلی، یا از پیشفرض مرکزی
+ * ادمین. fallback زنده حفظ میشود؛ چیزی که رد میشود قراردادی است که نوع خدمتِ
+ * فعال را عملاً صفر درصد میکند.
+ *
+ * نیامدنِ کلید `category_coverages` اصلاً به اینجا نمیرسد — آن حالت یعنی
+ * «قرارداد دستنخورده روی همان مسیر resolve بماند».
+ *
+ * @param list $rows
+ * @throws AppException وقتی نوع خدمتِ فعالی بدون درصد مؤثر بماند
+ */
+ private function assertEnabledCategoriesArePriced(TenantInsurance $contract, array $rows): void
+ {
+ $sent = [];
+ foreach ($rows as $row) {
+ $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null;
+ }
+
+ $overrides = $this->categoryCoverageRepo->percentMapFor($contract->getId());
+ $defaults = $this->coverageDefaults->percentMap($contract->getInsuranceId());
+
+ foreach ($this->serviceCategories->enabledKeys($contract->getEntityType(), $contract->getEntityId()) as $key) {
+ // ارسال صریحِ null یعنی «override را بردار»، پس نباید خودِ همان override
+ // که همین حالا حذف میشود، اعتبارسنجی را نجات بدهد.
+ // ستون قدیمیِ coverage_percent قرارداد عمداً fallback حساب نمیشود: با آن،
+ // نوع خدمتی که درصدش نیامده بیصدا نرخ نوع دیگر را ارث میبرد.
+ $percent = match (true) {
+ array_key_exists($key, $sent) && $sent[$key] !== null && $sent[$key] !== '' => (float) $sent[$key],
+ array_key_exists($key, $sent) => $defaults[$key] ?? 0.0,
+ default => $overrides[$key] ?? $defaults[$key] ?? 0.0,
+ };
+
+ if ($percent <= 0) {
+ throw new AppException(
+ ErrorCodes::ERR_VALIDATION_001,
+ sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()),
+ 422,
+ 'category_coverages',
+ );
+ }
+ }
+ }
+
+ /** @throws AppException وقتی درصد بیرون از بازهٔ ۰ تا ۱۰۰ باشد */
+ private function assertPercentInRange(float $percent, string $message, ?string $field = null): void
+ {
+ if ($percent < 0 || $percent > 100) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, $message, 422, $field);
+ }
+ }
+
/**
* Effective percentage per category plus where each value came from, so the panel
* can tell an explicit override apart from an inherited central default.
@@ -277,14 +335,14 @@ class TenantInsuranceService
?TenantServiceCoverage $override,
): CoverageRule {
$franchise = $this->isSupplementary($contract)
- ? ($override?->getFranchiseRials() ?? $contract->getFranchiseRials())
- : 0;
+ ? ($override?->getFranchisePercent() ?? $contract->getFranchisePercent())
+ : 0.0;
return new CoverageRule(
- coveragePercent: $this->resolvePercent($contract, $category, $override),
- franchiseRials: $franchise,
- ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
- covered: true,
+ coveragePercent: $this->resolvePercent($contract, $category, $override),
+ franchisePercent: $franchise,
+ ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
+ covered: true,
);
}
@@ -324,7 +382,7 @@ class TenantInsuranceService
return $kind === InsuranceType::Supplementary->value;
}
- /** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
+ /** @return array{covered: bool, coverage_percent: float|null, franchise_percent: float|null, ceiling_rials: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
$override = $this->coverageRepo->findOneFor($tenantInsuranceId, $serviceItemId);
@@ -336,15 +394,22 @@ class TenantInsuranceService
int $serviceItemId,
bool $covered,
?float $coveragePercent,
- ?int $franchiseRials,
+ ?float $franchisePercent,
?int $ceilingRials,
): void {
+ if ($coveragePercent !== null) {
+ $this->assertPercentInRange($coveragePercent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 'coverage_percent');
+ }
+ if ($franchisePercent !== null) {
+ $this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent');
+ }
+
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
?? new TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
- ->setFranchiseRials($franchiseRials)
+ ->setFranchisePercent($franchisePercent)
->setCeilingRials($ceilingRials);
$this->coverageRepo->save($override);
diff --git a/src/Insurance/ValueObject/CoverageRule.php b/src/Insurance/ValueObject/CoverageRule.php
index a3de1839..f6484db2 100644
--- a/src/Insurance/ValueObject/CoverageRule.php
+++ b/src/Insurance/ValueObject/CoverageRule.php
@@ -6,14 +6,17 @@ final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
- /** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمیکند. */
- public int $franchiseRials,
+ /**
+ * درصدِ سهم اجباری بیمار از مبلغ تحت پوشش — از سهم بیمه کسر میشود.
+ * فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمیکند.
+ */
+ public float $franchisePercent,
public ?int $ceilingRials,
public bool $covered = true,
) {}
public static function notCovered(): self
{
- return new self(0.0, 0, null, false);
+ return new self(0.0, 0.0, null, false);
}
}
diff --git a/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php b/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php
index ac6de07e..50e33362 100644
--- a/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php
+++ b/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php
@@ -27,7 +27,7 @@ class NumericFieldNormalizerSubscriber implements EventSubscriberInterface
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'insurance_price_rials', 'patient_share_rials', 'visit_price_rials',
'duration_minutes', 'duration', 'commission_percent', 'coverage',
- 'coverage_percent', 'franchise', 'ceiling', 'tax_percent',
+ 'coverage_percent', 'franchise', 'franchise_percent', 'ceiling', 'tax_percent',
'base_insurance_discount_percent', 'supplementary_discount_percent',
];
diff --git a/tests/Billing/BillingCalculatorTest.php b/tests/Billing/BillingCalculatorTest.php
index 8fc72aa7..73003447 100644
--- a/tests/Billing/BillingCalculatorTest.php
+++ b/tests/Billing/BillingCalculatorTest.php
@@ -27,8 +27,8 @@ class BillingCalculatorTest extends TestCase
public function testBaseAndSupplementary(): void
{
// کل 600,000؛ پایه 70% → 420,000؛ مکمل روی 180,000 با ~66.667% → 120,000؛ بیمار 60,000
- $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
- $supp = new CoverageRule(coveragePercent: 66.6667, franchiseRials: 0, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null);
+ $supp = new CoverageRule(coveragePercent: 66.6667, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, $supp);
@@ -43,7 +43,7 @@ class BillingCalculatorTest extends TestCase
public function testBaseOnly(): void
{
- $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(420_000, $b->baseInsuranceRials);
$this->assertSame(0, $b->supplementaryRials);
@@ -53,7 +53,7 @@ class BillingCalculatorTest extends TestCase
public function testBaseCeilingCapsShare(): void
{
// پایه 70% = 420,000 ولی سقف 300,000 → بیمار باقی را میدهد
- $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: 300_000);
+ $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: 300_000);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(300_000, $b->baseInsuranceRials);
$this->assertSame(300_000, $b->patientRials);
@@ -62,7 +62,7 @@ class BillingCalculatorTest extends TestCase
/** سناریوی مرجع کاربر: ویزیت 5,952,000 ریال با پوشش پایهٔ ۳۰٪ (بستری). */
public function testReferenceScenarioBasePercentOnly(): void
{
- $base = new CoverageRule(coveragePercent: 30.0, franchiseRials: 0, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 30.0, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(5_952_000), $base, null);
@@ -74,27 +74,66 @@ class BillingCalculatorTest extends TestCase
public function testBaseFranchiseDoesNotChargePatient(): void
{
// فرانشیز در بیمهٔ پایه بیاثر است: پایه 100% → سهم بیمار صفر.
- $base = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 100, franchisePercent: 50, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(0, $b->patientRials);
$this->assertSame(600_000, $b->baseInsuranceRials);
}
- public function testSupplementaryFranchiseAddedToPatient(): void
+ public function testSupplementaryFranchiseIsDeductedFromTheInsurerShare(): void
{
- $base = new CoverageRule(coveragePercent: 70, franchiseRials: 90_000, ceilingRials: null);
- $supp = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
+ // پایه 70% → 420,000؛ باقیمانده 180,000؛ تکمیلی 100% منهای فرانشیز 10% → 162,000.
+ $base = new CoverageRule(coveragePercent: 70, franchisePercent: 50, ceilingRials: null);
+ $supp = new CoverageRule(coveragePercent: 100, franchisePercent: 10, 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);
+ $this->assertSame(162_000, $b->supplementaryRials);
+ $this->assertSame(18_000, $b->patientRials);
+ $this->assertSame(
+ $b->totalRials,
+ $b->baseInsuranceRials + $b->supplementaryRials + $b->patientRials,
+ 'فرانشیز نباید جمع سهمها را از کل بیشتر کند'
+ );
+ }
+
+ /** سناریوی مرجع: بدون بیمهٔ پایه، تکمیلی ۹۰٪ با فرانشیز ۱۰٪. */
+ public function testSupplementaryOnlyWithFranchise(): void
+ {
+ $supp = new CoverageRule(coveragePercent: 90, franchisePercent: 10, ceilingRials: null);
+
+ $b = $this->calc->calculateItem(new Money(1_000_000), null, $supp);
+
+ $this->assertSame(800_000, $b->supplementaryRials);
+ $this->assertSame(200_000, $b->patientRials);
+ $this->assertSame($b->totalRials, $b->supplementaryRials + $b->patientRials);
+ }
+
+ public function testFullFranchiseLeavesNothingForTheSupplementary(): void
+ {
+ $supp = new CoverageRule(coveragePercent: 100, franchisePercent: 100, ceilingRials: null);
+
+ $b = $this->calc->calculateItem(new Money(600_000), null, $supp);
+
+ $this->assertSame(0, $b->supplementaryRials);
+ $this->assertSame(600_000, $b->patientRials);
+ }
+
+ public function testFranchiseNeverPushesTheInsurerShareBelowZero(): void
+ {
+ // فرانشیز بزرگتر از تعهد: سهم بیمه صفر میشود، نه منفی.
+ $supp = new CoverageRule(coveragePercent: 20, franchisePercent: 80, ceilingRials: null);
+
+ $b = $this->calc->calculateItem(new Money(600_000), null, $supp);
+
+ $this->assertSame(0, $b->supplementaryRials);
+ $this->assertSame(600_000, $b->patientRials);
}
public function testZeroPercentLeavesEverythingToPatient(): void
{
- $base = new CoverageRule(coveragePercent: 0, franchiseRials: 0, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 0, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(0, $b->baseInsuranceRials);
$this->assertSame(600_000, $b->patientRials);
@@ -102,7 +141,7 @@ class BillingCalculatorTest extends TestCase
public function testFullPercentLeavesNothingToPatient(): void
{
- $base = new CoverageRule(coveragePercent: 100, franchiseRials: 0, ceilingRials: null);
+ $base = new CoverageRule(coveragePercent: 100, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(600_000, $b->baseInsuranceRials);
$this->assertSame(0, $b->patientRials);
diff --git a/tests/Billing/ClaimsByPatientTest.php b/tests/Billing/ClaimsByPatientTest.php
index 2f3ed127..da36626d 100644
--- a/tests/Billing/ClaimsByPatientTest.php
+++ b/tests/Billing/ClaimsByPatientTest.php
@@ -9,6 +9,8 @@ use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\ValueObject\ShareBreakdown;
use App\Doctor\Entity\Doctor;
+use App\Insurance\Entity\Insurance;
+use App\Insurance\Enum\InsuranceType;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
@@ -40,8 +42,13 @@ class ClaimsByPatientTest extends ApiTestCase
$this->em->flush();
}
- /** Invoice of $total split into insurance/patient shares, with its claim(s). */
- private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending']): Invoice
+ /**
+ * Invoice of $total split into insurance/patient shares, with its claim(s).
+ *
+ * @param list $insuranceIds one per claim; defaults to synthetic ids when the
+ * test does not care which insurance it is
+ */
+ private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending'], array $insuranceIds = []): Invoice
{
$patient = $total - $baseShare - $suppShare;
@@ -58,7 +65,7 @@ class ClaimsByPatientTest extends ApiTestCase
$kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY;
$share = $i === 0 ? $baseShare : $suppShare;
- $claim = new Claim('doctor', $this->doctor->getId(), 1 + $i, $kind);
+ $claim = new Claim('doctor', $this->doctor->getId(), $insuranceIds[$i] ?? (1 + $i), $kind);
$claimItem = new ClaimItem($claim, (int) $item->getId(), $share);
$claim->addItem($claimItem);
if ($status !== Claim::STATUS_PENDING) {
@@ -118,6 +125,67 @@ class ClaimsByPatientTest extends ApiTestCase
self::assertSame('mixed', $res['data'][0]['overall_status']);
}
+ public function testEachRowCarriesTheInsurancesItsClaimsBelongTo(): void
+ {
+ $iran = new Insurance('بیمه ایران تست', InsuranceType::Basic);
+ $asia = new Insurance('بیمه آسیا تست', InsuranceType::Supplementary);
+ $this->em->persist($iran);
+ $this->em->persist($asia);
+ $this->em->flush();
+
+ $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending'], [
+ (int) $iran->getId(), (int) $asia->getId(),
+ ]);
+
+ $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
+ $row = $res['data'][0];
+
+ self::assertCount(2, $row['insurances']);
+ $names = array_column($row['insurances'], 'insurance_name');
+ self::assertContains('بیمه ایران تست', $names);
+ self::assertContains('بیمه آسیا تست', $names);
+ self::assertSame(
+ ['base', 'supplementary'],
+ array_values(array_unique(array_column($row['insurances'], 'kind'))),
+ );
+ }
+
+ public function testKindFilterKeepsOnlyThatKindOfClaim(): void
+ {
+ $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']);
+
+ $supp = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=supplementary', $this->owner);
+ self::assertSame(1, $supp['data'][0]['claims_count']);
+ self::assertSame(2_000_000, $supp['data'][0]['total_insurance_rials']);
+
+ $base = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=base', $this->owner);
+ self::assertSame(6_000_000, $base['data'][0]['total_insurance_rials']);
+ }
+
+ public function testAnUnknownKindIsRejected(): void
+ {
+ $body = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=bogus', $this->owner);
+
+ self::assertSame(422, $this->responseCode());
+ self::assertSame('kind', $body['errors'][0]['field']);
+ }
+
+ public function testSearchAlsoMatchesTheInsuranceName(): void
+ {
+ $asia = new Insurance('بیمه آسیا جستجو', InsuranceType::Supplementary);
+ $this->em->persist($asia);
+ $this->em->flush();
+
+ $this->invoiceWithClaims(10_000_000, 0, 4_000_000, ['pending'], [(int) $asia->getId()]);
+
+ $hit = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('آسیا'), $this->owner);
+ self::assertSame(1, $hit['meta']['totalRecords']);
+
+ $miss = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('بیمه ناموجود'), $this->owner);
+ self::assertSame(0, $miss['meta']['totalRecords']);
+ self::assertSame([], $miss['data']);
+ }
+
public function testStatusFilterNarrowsTheAggregation(): void
{
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
diff --git a/tests/Insurance/CoveragePercentResolutionTest.php b/tests/Insurance/CoveragePercentResolutionTest.php
index 817d84e1..679afccd 100644
--- a/tests/Insurance/CoveragePercentResolutionTest.php
+++ b/tests/Insurance/CoveragePercentResolutionTest.php
@@ -107,7 +107,11 @@ class CoveragePercentResolutionTest extends ApiTestCase
public function testServiceOverrideWinsOverEveryOtherLevel(): void
{
[$contract, $insurance, $item] = $this->makeContract(55.0);
- $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
+ // سرپایی هم پیشفرض دارد، وگرنه ذخیرهٔ درصدها با «درصد پوشش الزامی است» رد میشود.
+ $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->service->setServiceCoverage($contract, $item->getId(), true, 25.0, null, null);
@@ -139,7 +143,7 @@ class CoveragePercentResolutionTest extends ApiTestCase
);
$this->assertSame(30.0, $rule->coveragePercent);
- $this->assertSame(0, $rule->franchiseRials, 'فرانشیز در بیمهٔ پایه صفر میماند');
+ $this->assertSame(0.0, $rule->franchisePercent, 'فرانشیز در بیمهٔ پایه صفر میماند');
}
/** ویزیت خدمتِ سرپایی است. */
@@ -163,7 +167,10 @@ class CoveragePercentResolutionTest extends ApiTestCase
public function testRemovingTheOverrideReturnsToTheAdminDefault(): void
{
[$contract, $insurance] = $this->makeContract(55.0);
- $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
+ $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->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => null]]);
diff --git a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php
index a1a5e836..cfc081ce 100644
--- a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php
+++ b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php
@@ -70,6 +70,45 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
$this->assertSame('admin_default', $row['category_coverage_source']['outpatient']);
}
+ public function testAnEnabledServiceKindCannotBeLeftWithoutAPercent(): void
+ {
+ // بدون پیشفرض مرکزی: ارسال درصد فقط برای سرپایی، بستری را صفر رها میکند.
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+
+ $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ['key' => 'inpatient', 'coverage_percent' => null],
+ ],
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ $this->assertSame('category_coverages', $body['errors'][0]['field']);
+ $this->assertStringContainsString('خدمات بستری', $body['errors'][0]['message']);
+ }
+
+ public function testADisabledServiceKindNeedsNoPercent(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance();
+ $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [
+ 'service_categories' => [
+ ['key' => 'outpatient', 'enabled' => true],
+ ['key' => 'inpatient', 'enabled' => false],
+ ],
+ ]);
+
+ $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'category_coverages' => [
+ ['key' => 'outpatient', 'coverage_percent' => 70],
+ ],
+ ]);
+
+ $this->assertSame(201, $this->responseCode());
+ $this->assertEquals(70.0, $body['data']['data']['category_coverages']['outpatient']);
+ }
+
public function testListReturnsEffectivePercentsAndSources(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance();
@@ -113,8 +152,10 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
public function testPatchCanDropAnOverrideBackToTheAdminDefault(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance();
+ // سرپایی هم پیشفرض دارد، وگرنه ساخت قرارداد بهخاطر «درصد پوشش الزامی است» رد میشود.
static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
- ['key' => 'inpatient', 'coverage_percent' => 30],
+ ['key' => 'outpatient', 'coverage_percent' => 50],
+ ['key' => 'inpatient', 'coverage_percent' => 30],
]);
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
diff --git a/tests/Insurance/TenantInsuranceContractApiTest.php b/tests/Insurance/TenantInsuranceContractApiTest.php
index 7435cab2..0194ebec 100644
--- a/tests/Insurance/TenantInsuranceContractApiTest.php
+++ b/tests/Insurance/TenantInsuranceContractApiTest.php
@@ -35,7 +35,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'coverage_percent' => 70,
- 'franchise_rials' => 500_000,
+ 'franchise_percent' => 15,
'annual_ceiling_rials' => 20_000_000,
'effective_from' => 1_700_000_000,
'effective_to' => 1_800_000_000,
@@ -45,7 +45,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertSame(201, $this->responseCode());
$row = $body['data']['data'];
$this->assertEquals(70.0, $row['coverage_percent']);
- $this->assertSame(500_000, $row['franchise_rials']);
+ $this->assertEquals(15.0, $row['franchise_percent']);
$this->assertSame(20_000_000, $row['annual_ceiling_rials']);
$this->assertSame(1_700_000_000, $row['effective_from']);
$this->assertSame(1_800_000_000, $row['effective_to']);
@@ -53,6 +53,37 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertTrue($row['is_active']);
}
+ public function testFranchiseAboveHundredIsRejected(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary);
+
+ $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'coverage_percent' => 80,
+ 'franchise_percent' => 150,
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ $this->assertSame('franchise_percent', $body['errors'][0]['field']);
+ }
+
+ public function testNegativeFranchiseOnEditIsRejected(): void
+ {
+ [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary);
+ $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
+ 'insurance_id' => $insurance->getId(),
+ 'coverage_percent' => 80,
+ ]);
+ $uuid = $created['data']['data']['uuid'];
+
+ $body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [
+ 'franchise_percent' => -5,
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ $this->assertSame('franchise_percent', $body['errors'][0]['field']);
+ }
+
public function testKindDefaultsToCatalogTypeWhenOmitted(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic);
@@ -77,7 +108,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [
'coverage_percent' => 90,
- 'franchise_rials' => 123_000,
+ 'franchise_percent' => 20,
'annual_ceiling_rials' => null,
'effective_to' => 1_900_000_000,
'kind' => 'supplementary',
@@ -85,7 +116,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertSame(200, $this->responseCode());
$this->assertEquals(90.0, $body['data']['data']['coverage_percent']);
- $this->assertSame(123_000, $body['data']['data']['franchise_rials']);
+ $this->assertEquals(20.0, $body['data']['data']['franchise_percent']);
$this->assertNull($body['data']['data']['annual_ceiling_rials']);
$this->assertSame(1_900_000_000, $body['data']['data']['effective_to']);
$this->assertSame('supplementary', $body['data']['data']['kind']);