diff --git a/.claude/prompt/appointment-insurance-service-type.md b/.claude/prompt/appointment-insurance-service-type.md new file mode 100644 index 00000000..82cbe902 --- /dev/null +++ b/.claude/prompt/appointment-insurance-service-type.md @@ -0,0 +1,349 @@ +# انتخاب نوع خدمت بیمه (سرپایی/بستری) در نوبت و انتشار آن تا فاکتور + +## پروژه + +`clinicpro` (بک‌اند Symfony + پنل ادمین React). دامنه‌ها: `src/Insurance/`، `src/Appointment/`، `src/Patient/`، `src/Billing/`، `assets/admin/`. +سایت عمومی `nobat724_front` هیچ‌جا بیمه را در فرآیند رزرو انتخاب نمی‌کند؛ فیلدهای جدید نوبت optional و nullable هستند → پرامپت همتا لازم نیست (فقط قرارداد پاسخ `GET /api/v1/appointment/{uuid}` دو کلید جدید می‌گیرد که مصرف‌کنندهٔ فعلی ندارد). + +## زمینه + +در پرامپت قبلی (`insurance-coverage-percent-rework.md`) درصد پوشش هر بیمه به تفکیک **نوع خدمت** (`ServiceCategory`: `outpatient` / `inpatient`) پیاده شد: پیش‌فرض مرکزی ادمین (`insurance_coverage_defaults`)، override قرارداد (`tenant_insurance_category_coverage`) و زنجیرهٔ resolve در `TenantInsuranceService::resolvePercent`. نوع خدمتِ هر «آیتم سرویس» هم روی `ServiceItem.service_category` ذخیره می‌شود و **ویزیت تا الآن همیشه `outpatient` فرض شده است**. + +اما در جریان کاری واقعی، پزشک ممکن است اصلاً خدمات بستری ارائه نکند، یا هر دو را ارائه کند و لازم باشد سرِ پذیرش مشخص شود این مراجعه سرپایی است یا بستری. الآن هیچ‌کدام از این‌ها وجود ندارد: + +1. پزشک/کلینیک نمی‌تواند بگوید بیمه‌هایش کدام نوع خدمات را پوشش می‌دهند. +2. نوبت هیچ فیلدی برای «بیمهٔ انتخابی» و «نوع خدمت» ندارد → مودال «قطعی کردن نوبت» فقط جمع کل را نشان می‌دهد و کل مبلغ سهم بیمار می‌شود. +3. `PatientService::autoCreateOnAppointmentConfirm` عامدانه بیمه را نادیده می‌گیرد: `applyShares($gross, 0, 0, $gross)`. +4. پرونده بیمار (بخش سرویس‌ها) و فاکتور، نوع خدمت و نام بیمه را نشان نمی‌دهند. + +## هدف + +الف) در **تنظیمات ← مدیریت بیمه** (`/admin/insurance-pricing`) یک تنظیم **سراسری برای همهٔ بیمه‌های همان پزشک/کلینیک**: کدام نوع خدمات بیمه فعال است — «خدمات سرپایی» و/یا «خدمات بستری». (این تنظیم per-insurance نیست؛ یک بار برای کل tenant.) + +ب) اگر فقط یک نوع فعال باشد، همان به‌صورت خودکار مبنای محاسبه است و **هیچ انتخابی از کاربر پرسیده نمی‌شود**. اگر هر دو فعال باشند، انتخاب نوع خدمت نمایش داده شود در: +- مودال «قطعی کردن نوبت» (که از تایم‌لاین نوبت‌ها و صفحهٔ جزئیات نوبت باز می‌شود) +- صفحهٔ ویرایش نوبت (`/admin/appointments/{uuid}/edit`) + +ج) پس از انتخاب نوع خدمت، لیست **بیمه‌های پایهٔ فعالِ همان پزشک** نمایش داده شود؛ با انتخاب بیمه، سهم بیمه و مبلغ پرداختی بیمار خودکار محاسبه و نمایش داده شود (آینهٔ `BillingCalculator`، همان قاعدهٔ `round(کل × درصد ÷ 100)`). + +د) نوع خدمت + بیمهٔ انتخابی روی نوبت ذخیره شود، به مراجعه (`PatientSession`) منتقل شود، در **پرونده بیمار ← سرویس‌ها** نمایش داده شود و در **فاکتور** درج شود (نوع خدمت، بیمه، سهم بیمه، سهم بیمار). + +## فایل‌های مرتبط + +| فایل | نقش | +|---|---| +| `src/Insurance/Enum/ServiceCategory.php` | enum موجود `outpatient`/`inpatient` (+ `values()`, `label()`, `tryFromValue()`) | +| `src/Insurance/Entity/EntityInsurancePricing.php` | تنظیمات بیمهٔ tenant (ردیف `insurance_id = NULL` = قیمت ویزیت آزاد + `require_visit_price`) | +| `src/Insurance/Controller/InsuranceController.php` | `GET/PUT /api/v1/insurance-pricing` (`pricingPayload()`) + قراردادهای tenant | +| `src/Insurance/Service/TenantInsuranceService.php` | `coverageRule()` (ویزیت، الآن hardcode `Outpatient`)، `coverageRuleForService()`، `resolvePercent()` | +| `src/Appointment/Entity/Appointment.php` | **فاقد** فیلد بیمه/نوع خدمت | +| `src/Appointment/Controller/AppointmentController.php` | `PATCH /api/v1/appointment/{uuid}` (خط ۱۰۵۶) و `POST /api/v1/appointment/{uuid}/confirm` (خط ۹۸۵) | +| `src/Appointment/Service/AppointmentConfirmationService.php` | `confirmWithPayments()` — تراکنش قطعی‌کردن + ساخت مراجعه + پرداخت‌ها | +| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm()` / `autoCreateForEntity()` / `calculateFinalPrice()` / `createSession()` | +| `src/Patient/Entity/PatientSession.php` | `insurance_base_id`, `base_insurance_rials`, … — **فاقد** نوع خدمت | +| `src/Billing/Service/InvoiceService.php` | `createFromSession()` — همان `CoverageRule` | +| `src/Billing/Entity/Invoice.php` | `base_insurance_id`, `base_insurance_rials`, … — **فاقد** نوع خدمت | +| `assets/admin/pages/InsurancePricingPage.tsx` | صفحهٔ «مدیریت بیمه» داخل `SettingsLayout active="insurance"` | +| `assets/admin/components/TenantInsuranceContracts.tsx` | کارت قراردادهای بیمه + `useServiceCategories()` | +| `assets/admin/components/appointments/ConfirmAppointmentModal.tsx` | مودال «قطعی کردن نوبت» | +| `assets/admin/components/appointments/TurnsTimeline.tsx` | تایم‌لاین نوبت‌ها (خط ۱۴۵ مودال را باز می‌کند) | +| `assets/admin/pages/AppointmentEditPage.tsx` | صفحهٔ ویرایش نوبت | +| `assets/admin/components/SessionServiceCard.tsx` | کارت سرویس‌های مراجعه در پرونده بیمار | +| `assets/admin/components/InvoiceSummaryModal.tsx` | نمایش فاکتور | +| `assets/admin/hooks/useServiceCategories.ts` | لیست انواع خدمت از `GET /api/v1/service-categories` | +| `docs/api/insurance.md`, `docs/api/appointment.md`, `docs/api/patient.md`, `docs/api/billing.md` | مستندات | + +## وضعیت فعلی + +### ۱) قطعی‌کردن نوبت، بیمه را کاملاً نادیده می‌گیرد — `src/Patient/Service/PatientService.php:212-226` + +```php +// خطوط هزینه‌ی سرویس (قیمت snapshot از خود سرویس، بدون ورود دستی). +$servicesTotal = 0; +$lines = []; +foreach ($appointment->getServiceItems() as $item) { + $line = new SessionService($session, $item, null, 1); + $lines[] = $line; + $servicesTotal += $line->getLineTotalRials(); +} + +$session->setServicesTotalRials($servicesTotal); +// پذیرش خودکار بیمه‌ای ندارد: تمام مبلغ سهم بیمار است. +$gross = $servicesTotal + $visitPrice; +$session->applyShares($gross, 0, 0, $gross); +``` + +### ۲) قاعدهٔ ویزیت نوع خدمت را نمی‌پذیرد — `src/Insurance/Service/TenantInsuranceService.php` + +```php +public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule +{ + // ... + return $this->buildRule($contract, ServiceCategory::Outpatient, null); +} +``` + +### ۳) مودال قطعی‌کردن فقط جمع کل را می‌شناسد — `assets/admin/components/appointments/ConfirmAppointmentModal.tsx:130-146` + +```tsx +const visitPrice = Number(appt?.visit_price_rials ?? 0); +const services = appt?.service_items ?? []; +const servicesTotal = useMemo( + () => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0), + [services], +); +const total = visitPrice + servicesTotal; + +// ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛ +useEffect(() => { + if (!open || touched || total <= 0) return; + setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r))); +}, [open, touched, total]); +``` + +و بدنهٔ ارسالی هیچ فیلد بیمه‌ای ندارد: + +```tsx +api.post(`/api/v1/appointment/${appointmentUuid}/confirm`, { + version: appt?.version, + payments: rows.filter(...).map(...), +}) +``` + +### ۴) صفحهٔ ویرایش نوبت — `assets/admin/pages/AppointmentEditPage.tsx:82-94` + +```tsx +const save = useMutation({ + mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, { + slot_start: toEpoch(date, start), + slot_end: toEpoch(date, end), + service_section_uuid: sectionUuid, + service_item_uuid: itemUuid, + staff_uuid: staffUuid, + deposit_required: depositRequired, + deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, + note, + ...(status !== a?.status ? { status } : {}), + version: a?.version, + }), +``` + +## وظایف + +> ترتیب: ۱ → ۹. بک‌اند اول، بعد فرانت، بعد مستندات/تست. هر گام مستقل تست‌شدنی باشد. + +### ۱. تنظیم سراسریِ «نوع خدمات بیمه» برای هر tenant + +Entity جدید `src/Insurance/Entity/TenantServiceCategorySetting.php` (`tenant_service_category_settings`) + `src/Insurance/Repository/TenantServiceCategorySettingRepository.php`: + +```php +#[ORM\Entity(repositoryClass: TenantServiceCategorySettingRepository::class)] +#[ORM\Table(name: 'tenant_service_category_settings')] +#[ORM\UniqueConstraint(name: 'uniq_tenant_service_category_setting', columns: ['entity_type', 'entity_id', 'service_category'])] +class TenantServiceCategorySetting +{ + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] + private string $entityType; // doctor|clinic — همان الگوی EntityInsurancePricing + + #[ORM\Column(name: 'entity_id', type: 'integer')] + private int $entityId; + + #[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)] + private ServiceCategory $serviceCategory; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $enabled = true; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + // getters/setters + toArray() +} +``` + +Service جدید `src/Insurance/Service/TenantServiceCategoryService.php`: + +```php +/** @return list نوع‌های فعال؛ نبودِ ردیف = همهٔ نوع‌ها فعال (سازگاری عقب‌رو) */ +public function enabledKeys(string $entityType, int $entityId): array; + +/** @return list برای UI — همیشه همهٔ caseها */ +public function settingsRows(string $entityType, int $entityId): array; + +/** + * نوع خدمتِ پیش‌فرض: اگر فقط یک نوع فعال باشد همان، وگرنه null + * (یعنی کاربر باید انتخاب کند). + */ +public function defaultCategory(string $entityType, int $entityId): ?ServiceCategory; + +public function isEnabled(string $entityType, int $entityId, ServiceCategory $category): bool; + +/** @param list $rows */ +public function save(string $entityType, int $entityId, array $rows): void; // حداقل یک نوع باید فعال بماند +``` + +migration + `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` (روی `db` و `--env=test`). **backfill لازم نیست**: نبودِ ردیف = «همه فعال». + +### ۲. انتشار تنظیم در API موجود (بدون endpoint جدید) + +`GET /api/v1/insurance-pricing` همین‌حالا تنظیماتِ سراسریِ بیمهٔ tenant را می‌دهد (`free_visit_price_rials`, `require_visit_price`, `insurances[]`), پس همان را توسعه بده — endpoint جدید نساز: + +- در `pricingPayload()` کلید تازه: + ```json + "service_categories": [ + { "key": "outpatient", "label": "خدمات سرپایی", "enabled": true }, + { "key": "inpatient", "label": "خدمات بستری", "enabled": false } + ], + "default_service_category": "outpatient" + ``` + `default_service_category` همان خروجی `defaultCategory()` است (`null` وقتی هر دو فعال‌اند). +- `PUT /api/v1/insurance-pricing` بدنهٔ اختیاری `service_categories: [{ key, enabled }]` بپذیرد. اعتبارسنجی: `key ∈ ServiceCategory::values()` و **حداقل یک نوع فعال** → وگرنه `$this->error(ErrorCodes::ERR_VALIDATION_001, 'حداقل یک نوع خدمت باید فعال باشد', 422, 'service_categories')`. +- همان قواعد دسترسیِ فعلیِ این endpoint (منشی: `insurances.view`/`update`، و `doctor_uuid` برای کلینیک چندپزشکه) بدون تغییر اعمال شود. + +### ۳. فیلدهای بیمه روی نوبت + +`src/Appointment/Entity/Appointment.php` دو ستون جدید: + +```php +/** نوع خدمتِ بیمه‌ایِ این نوبت؛ null = هنوز انتخاب نشده. */ +#[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)] +private ?ServiceCategory $insuranceServiceCategory = null; + +/** بیمهٔ پایهٔ انتخاب‌شده (ارجاع خام int، مثل TenantInsurance/Tariff). */ +#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)] +private ?int $insuranceBaseId = null; +``` + +- در `toArray()`: `insurance_service_category`, `insurance_service_category_label`, `insurance_base_id`, `insurance_base_name` (نام از `InsuranceRepository`؛ در لیست‌ها بدون N+1 — اگر لازم شد نام را در کنترلر با یک map تزریق کن، نه داخل entity). +- migration. + +`PATCH /api/v1/appointment/{uuid}` (خط ۱۰۵۶) این دو فیلد را بپذیرد؛ اعتبارسنجی مشترک را در یک helper خصوصی بگذار تا `confirm` هم از آن استفاده کند: + +```php +/** + * نوع خدمت و بیمهٔ پایه را روی نوبت می‌نشاند. + * - نوع خدمت باید در تنظیمات همان tenant فعال باشد + * - بیمه باید قرارداد فعال داشته باشد و پایه باشد + */ +private function applyAppointmentInsurance(Appointment $appointment, array $data): ?JsonResponse +``` + +خطاها: `422 ERR_VALIDATION_001` با فیلد `insurance_service_category` (نوع نامعتبر یا غیرفعال) · `422 ERR_VALIDATION_001` با فیلد `insurance_base_id` (بیمه بدون قرارداد فعال یا نوعش تکمیلی است). + +`POST /api/v1/appointment/{uuid}/confirm` هم همان دو فیلد را در بدنه بپذیرد (انتخاب سرِ پذیرش) و **قبل از** `confirmWithPayments` روی نوبت بنشاند تا مراجعه با بیمهٔ درست ساخته شود. + +### ۴. نوع خدمت روی مراجعه + محاسبهٔ واقعیِ سهم در قطعی‌کردن + +`src/Patient/Entity/PatientSession.php`: ستون `insurance_service_category` (nullable, `enumType: ServiceCategory::class`) + getter/setter + کلید در `toArray()` (`insurance_service_category`, `insurance_service_category_label`). migration. + +`TenantInsuranceService::coverageRule()` یک پارامتر اختیاری بگیرد و پیش‌فرضش رفتار فعلی بماند: + +```php +public function coverageRule( + string $entityType, + int $entityId, + ?int $insuranceId, + ServiceCategory $category = ServiceCategory::Outpatient, +): CoverageRule +``` + +`PatientService::calculateFinalPrice()` هم پارامتر اختیاری `?ServiceCategory $visitCategory = null` بگیرد و برای ویزیت آن را (یا `Outpatient` در نبودش) به `coverageRule()` بدهد. سهم خدمات بدون تغییر از `service_category` خودِ `ServiceItem` می‌آید. + +`PatientService::autoCreateForEntity()` جای `applyShares($gross, 0, 0, $gross)`: + +```php +$session->setInsuranceBaseId($appointment->getInsuranceBaseId()); +$session->setInsuranceServiceCategory($category); // از نوبت، یا defaultCategory() همان tenant +$session->setBaseInsuranceDiscountPercent($this->contractPercent(...)); // snapshot نمایشی + +$calc = $this->calculateFinalPrice($visitPrice, $serviceLines, $entityType, $entityId, $appointment->getInsuranceBaseId(), null, $category); +$session->applyShares( + $calc['gross_total_rials'], + $calc['base_insurance_rials'], + $calc['supplementary_insurance_rials'], + $calc['patient_share_rials'], +); +``` + +- اگر نوبت بیمه ندارد، رفتار فعلی (کل مبلغ سهم بیمار) دقیقاً حفظ شود. +- نوع خدمتِ مؤثر: `appointment->getInsuranceServiceCategory() ?? $categoryService->defaultCategory(...) ?? ServiceCategory::Outpatient`. +- `createSession()` و مسیر `PATCH /api/v1/session/{uuid}` هم `insurance_service_category` را بپذیرند و به `calculateFinalPrice()` بدهند (هم‌خوان با `MyPatientsPage`). + +### ۵. مودال «قطعی کردن نوبت» + +`assets/admin/components/appointments/ConfirmAppointmentModal.tsx`: + +- `useQuery` روی `/api/v1/insurance-pricing` (برای `service_categories` + `default_service_category`) و `/api/v1/billing/tenant-insurances` (قراردادها). هر دو `enabled: open`. +- state: `serviceCategory` (init: مقدار نوبت، وگرنه `default_service_category`)، `insuranceId` (init: `appt.insurance_base_id`). +- `SearchableSelect` «نوع خدمت» **فقط** وقتی `service_categories.filter(c => c.enabled).length > 1` رندر شود؛ در غیر این صورت نوع فعال بی‌سروصدا استفاده شود (بدون UI). +- `SearchableSelect` «بیمه» با قراردادهای `is_active === true` و `insurance_kind === 'basic'`. +- محاسبهٔ زنده (آینهٔ `BillingCalculator`، همان `patientShareOf` که در `assets/admin/components/session/CreateStep.tsx` export شده — از همان استفاده کن، منطق تازه ننویس): + - درصد از `contract.category_coverages[serviceCategory]` (fallback: `contract.coverage_percent`). + - ویزیت با نوع خدمتِ انتخابی، هر سرویس با `service_category` خودش. + - دو ردیف نمایشی جدید در خلاصهٔ مبالغ: «سهم بیمه» و «سهم بیمار (قابل پرداخت)» با `formatRial`. +- پیش‌فرضِ ردیف اولِ پرداخت از `total` به **سهم بیمار** تغییر کند (وقتی بیمه انتخاب شده)؛ سقف `overpaid` هم بر همان مبنا. +- بدنهٔ `confirm` دو کلید تازه بفرستد: + ```ts + api.post(`/api/v1/appointment/${appointmentUuid}/confirm`, { + version: appt?.version, + ...(serviceCategory ? { insurance_service_category: serviceCategory } : {}), + ...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}), + payments: [...], + }) + ``` + +### ۶. تایم‌لاین نوبت‌ها + +`assets/admin/components/appointments/TurnsTimeline.tsx`: +- کارت نوبت، وقتی `insurance_service_category` یا `insurance_base_name` دارد، یک چیپ کوچک نمایش دهد (`سرپایی · بیمه ایران`) با همان توکن‌های موجود (`--primary-soft` / `--text-2`)؛ طراحی تازه نساز. +- `appointment` را به `ConfirmAppointmentModal` پاس بده (`appointment={a}`) تا درخواست جزئیات تکراری نزند و مقدارهای فعلی بیمه در مودال پیش‌پر شوند. + +### ۷. صفحهٔ ویرایش نوبت + +`assets/admin/pages/AppointmentEditPage.tsx`: +- `AppointmentDetail` دو فیلد جدید بگیرد؛ state + hydrate در همان `useEffect`. +- دو `SearchableSelect` («نوع خدمت بیمه» با همان شرط «هر دو فعال»، و «بیمه») در بلوک مشخصات، با همان `label` استایل موجود. +- پیش‌نمایش «سهم بیمه / سهم بیمار» زیر انتخاب بیمه (همان تابع مشترک محاسبه). +- `save.mutationFn` دو کلید جدید را در بدنهٔ PATCH بفرستد. + +### ۸. پرونده بیمار (سرویس‌ها) + فاکتور + +`assets/admin/components/SessionServiceCard.tsx`: +- در بخش تفکیک بیمه، دو ردیف تازه: «نوع خدمت بیمه» (`insurance_service_category_label`) و «بیمه» (نام بیمه). داده از `PatientSession::toArray()`. +- نام بیمه در پاسخ session لازم است: در `src/Patient/Controller/PatientController.php` (مسیر لیست/جزئیات مراجعه) نام‌ها را با یک کوئری map شده اضافه کن (`insurance_base_name`) — بدون N+1 و بدون گذاشتن ریپازیتوری داخل entity. + +`src/Billing/Entity/Invoice.php` + `src/Billing/Service/InvoiceService.php`: +- ستون `service_category` روی `invoices` (nullable, enumType) که از `PatientSession::getInsuranceServiceCategory()` snapshot می‌شود؛ migration. +- `Invoice::toArray()` → `service_category`, `service_category_label`؛ و `base_insurance_name` در پاسخ کنترلر فاکتور (همان الگوی map). + +`assets/admin/components/InvoiceSummaryModal.tsx`: +- در هدر/خلاصهٔ فاکتور: «نوع خدمت بیمه»، «بیمه»، و ستون‌های موجود «سهم بیمه پایه» و «سهم بیمار» حفظ شوند (خط ۱۲۰ فعلی). + +### ۹. مستندات و تست + +- `docs/api/insurance.md`: کلیدهای `service_categories` و `default_service_category` در `GET/PUT /api/v1/insurance-pricing` + قاعدهٔ «حداقل یک نوع فعال» + توضیح این‌که این تنظیم سراسری است نه per-insurance. +- `docs/api/appointment.md`: فیلدهای `insurance_service_category` / `insurance_base_id` در `GET`, `PATCH`, و بدنهٔ `POST /confirm` با خطاهایشان. +- `docs/api/patient.md`: `insurance_service_category` روی مراجعه + این‌که قطعی‌کردن نوبت دیگر کل مبلغ را سهم بیمار نمی‌گذارد. +- `docs/api/billing.md`: `service_category` روی فاکتور. +- PHPUnit: + - `TenantServiceCategoryService`: نبودِ ردیف → همه فعال · یک نوع فعال → `defaultCategory()` همان · هر دو فعال → `null` · غیرفعال‌کردن همه → ۴۲۲. + - `PUT /api/v1/insurance-pricing` با `service_categories` نامعتبر → ۴۲۲. + - `PATCH /api/v1/appointment/{uuid}` با نوع غیرفعال → ۴۲۲؛ با بیمهٔ بدون قرارداد فعال → ۴۲۲. + - `POST /confirm` با `insurance_base_id`: مراجعهٔ ساخته‌شده باید `base_insurance_rials` و `patient_share_rials` درست داشته باشد (سناریوی مرجع: ویزیت `5_952_000` ریال، پوشش بستری ۳۰٪ → `1_785_600` / `4_166_400`). + - `InvoiceService`: `service_category` روی فاکتور snapshot شود. +- Vitest: `ConfirmAppointmentModal` (پنهان‌بودن انتخاب نوع خدمت وقتی فقط یکی فعال است؛ محاسبهٔ سهم بیمه/بیمار؛ فیلدهای بدنهٔ confirm)، `AppointmentEditPage` (ارسال دو فیلد جدید). +- اجرای واقعی: `ddev exec php bin/phpunit` · `ddev exec php vendor/bin/phpstan analyse` · `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn dev` · `npx vitest run` (⚠️ vitest داخل ddev به‌خاطر معماری esbuild اجرا نمی‌شود؛ روی host اجرا کن). + +## نکات مهم + +- **منبع واحد محاسبه:** فقط `BillingCalculator` (سرور) و `patientShareOf` (آینهٔ کلاینت در `CreateStep.tsx`). هیچ فرمول درصدیِ موازیِ جدیدی در مودال قطعی‌کردن یا صفحهٔ ویرایش نوبت ننویس؛ همان تابع را import کن. +- **قاعدهٔ بیمهٔ پایه:** `سهم پایه = round(کل × درصد ÷ 100)` و `سهم بیمار = کل − سهم پایه`. فرانشیز فقط در قرارداد `supplementary`. +- **زنجیرهٔ درصد** دست‌نخورده باقی بماند: service override → override قرارداد برای نوع خدمت → پیش‌فرض مرکزی ادمین → `coverage_percent` قرارداد. +- **سازگاری عقب‌رو:** نبودِ ردیف در `tenant_service_category_settings` یعنی هر دو نوع فعال؛ نوبتِ بدون بیمه باید دقیقاً مثل امروز رفتار کند (کل مبلغ سهم بیمار). +- **نوعِ غیرفعال روی دادهٔ قدیمی:** اگر نوبتی نوع خدمتی دارد که بعداً غیرفعال شده، محاسبه با همان مقدارِ ذخیره‌شده انجام شود (snapshot)، ولی در فرم فقط نوع‌های فعال قابل انتخاب باشند. +- **بیمهٔ تکمیلی** در این پرامپت به نوبت اضافه نمی‌شود (فقط پایه) — `calculateFinalPrice` پارامتر تکمیلی را `null` می‌گیرد؛ ساختار را طوری بنویس که افزودنش بعداً یک فیلد باشد. +- **الگوهای پروژه:** `$this->success()` / `$this->paginated()` / `$this->error()` · لیست‌های ادمین `getArrayResult()` · تاریخ‌ها Unix timestamp · رشته‌های UI فارسی · در فرانت همیشه `SearchableSelect` (هرگز ` toggle(row.key)} + /> + + + {row.label} + + ))} + + + ); +} diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx index 33bbaa79..2f838bf6 100644 --- a/assets/admin/components/InvoiceSummaryModal.test.tsx +++ b/assets/admin/components/InvoiceSummaryModal.test.tsx @@ -101,4 +101,31 @@ describe('InvoiceSummaryModal', () => { expect(get).not.toHaveBeenCalled(); expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument(); }); + + it('فاکتور بیمه‌ای: نوع خدمت، نام بیمه و سهم‌ها درج می‌شوند', async () => { + mockInvoice({ + ...baseInvoice, + base_insurance_rials: 1_785_600, + patient_rials: 4_166_400, + total_rials: 5_952_000, + service_category: 'inpatient', + service_category_label: 'خدمات بستری', + base_insurance_name: 'بیمه ایران', + items: [{ uuid: 'it1', title: 'ویزیت', quantity: 1, total_rials: 5_952_000, patient_rials: 4_166_400 }], + }); + renderWithProviders( {}} />); + + await waitFor(() => expect(screen.getByText('اطلاعات بیمه')).toBeInTheDocument()); + expect(screen.getByText('نوع خدمت بیمه')).toBeInTheDocument(); + expect(screen.getByText('خدمات بستری')).toBeInTheDocument(); + expect(screen.getByText('بیمه ایران')).toBeInTheDocument(); + }); + + it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => { + mockInvoice(baseInvoice); + renderWithProviders( {}} />); + + await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument()); + expect(screen.queryByText('اطلاعات بیمه')).not.toBeInTheDocument(); + }); }); diff --git a/assets/admin/components/InvoiceSummaryModal.tsx b/assets/admin/components/InvoiceSummaryModal.tsx index 128eb383..d9f8b3a1 100644 --- a/assets/admin/components/InvoiceSummaryModal.tsx +++ b/assets/admin/components/InvoiceSummaryModal.tsx @@ -20,6 +20,11 @@ interface SessionData { interface Invoice { uuid: string; status: string; issued_at: number; total_rials: number; base_insurance_rials: number; supplementary_rials: number; patient_rials: number; + /** نوع خدمتِ بیمه‌ای که فاکتور با آن محاسبه شده + نام بیمه‌ها. */ + service_category?: string | null; + service_category_label?: string | null; + base_insurance_name?: string | null; + supplementary_insurance_name?: string | null; items: InvoiceItem[]; session?: SessionData | null; } @@ -101,6 +106,18 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}, ]]} /> + {(inv.base_insurance_name || inv.service_category_label) && ( + + )}
+ {session.insurance_service_category_label && ( + + )} + {session.insurance_base_name && } + {(session.base_insurance_rials ?? 0) > 0 && ( + + )} {!paid && }
diff --git a/assets/admin/components/TenantInsuranceContracts.test.tsx b/assets/admin/components/TenantInsuranceContracts.test.tsx index 66aa3cff..e1e9883f 100644 --- a/assets/admin/components/TenantInsuranceContracts.test.tsx +++ b/assets/admin/components/TenantInsuranceContracts.test.tsx @@ -71,6 +71,14 @@ describe('contractSummary', () => { const s = contractSummary(mk({ coverage_percent: 90, category_coverages: undefined }), categories); expect(s).toContain('پوشش ۹۰٪'); }); + + /** تا برچسب‌های فارسی از سرور نرسیده‌اند، نباید کلید انگلیسی نشان داده شود. */ + it('بدون برچسب‌های سرور، کلید انگلیسی نشان نمی‌دهد', () => { + const s = contractSummary(mk({ coverage_percent: 90, category_coverages: { outpatient: 10, inpatient: 30 } }), []); + expect(s).not.toContain('outpatient'); + expect(s).not.toContain('inpatient'); + expect(s).toContain('پوشش ۹۰٪'); + }); }); describe('TenantInsuranceContracts', () => { diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx index b6fb4f82..b92e4989 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -39,9 +39,11 @@ export function filterInsurances(list: Contract[], query: string): Contract[] { */ export function contractSummary(c: Contract, categories: ServiceCategoryOption[] = []): string { const percents = c.category_coverages ?? {}; - const labelled = categories.length > 0 - ? categories.filter((cat) => cat.key in percents).map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`) - : Object.entries(percents).map(([key, percent]) => `${key} ${formatNumber(percent)}٪`); + // برچسب‌ها فقط از سرور می‌آیند؛ تا نیامده‌اند خطِ قدیمیِ «پوشش X٪» نشان داده می‌شود + // (کلید انگلیسیِ نوع خدمت هرگز به UI نمی‌رسد). + const labelled = categories + .filter((cat) => cat.key in percents) + .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) { @@ -310,6 +312,7 @@ function ContractCard({ contract: c, categories, open, onToggleRow, onEdit, onTo function ContractDetails({ contract: c, categories }: { contract: Contract; categories: ServiceCategoryOption[] }) { const percents = c.category_coverages ?? {}; const isSupplementary = c.insurance_kind === 'supplementary'; + const perCategory = categories.filter((cat) => cat.key in percents); return (
- {categories.filter((cat) => cat.key in percents).map((cat) => ( + {perCategory.length === 0 && ( + + )} + {perCategory.map((cat) => ( { expect(submit).not.toBeDisabled(); fireEvent.change(amountInputs()[0], { target: { value: '9000000' } }); - expect(screen.getByText('مجموع پرداخت‌ها از جمع کل بیشتر است.')).toBeInTheDocument(); + expect(screen.getByText('مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument(); expect(submit).toBeDisabled(); }); @@ -115,3 +115,147 @@ describe('ConfirmAppointmentModal', () => { expect(amountInputs()).toHaveLength(1); }); }); + +// ── بیمه: نوع خدمت + محاسبهٔ سهم ────────────────────────────────────────────── + +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, + category_coverages: { outpatient: 70, inpatient: 30 }, +}; + +/** ویزیت ۵٬۹۵۲٬۰۰۰ ریال، بدون خدمت — سناریوی مرجعِ سهم بیمه. */ +const referenceAppointment = { + uuid: 'a1', version: 1, patient_name: 'محمد رضایی', + visit_price_rials: 5_952_000, service_items: [], +}; + +function mockInsurance( + categories: { key: string; label: string; enabled: boolean }[], + freeVisitPriceRials = 0, +) { + get.mockImplementation((url: string) => { + if (url === '/api/v1/insurance-pricing') { + return Promise.resolve({ + success: true, + data: { + service_categories: categories, + free_visit_price_rials: freeVisitPriceRials, + default_service_category: categories.filter((c) => c.enabled).length === 1 + ? categories.find((c) => c.enabled)!.key + : null, + }, + }); + } + if (url === '/api/v1/billing/tenant-insurances') { + return Promise.resolve({ success: true, data: { data: [CONTRACT] } }); + } + return Promise.resolve({ success: true, data: [] }); + }); +} + +const BOTH = [ + { key: 'outpatient', label: 'خدمات سرپایی', enabled: true }, + { key: 'inpatient', label: 'خدمات بستری', enabled: true }, +]; + +function renderReference() { + return renderWithProviders( + {}} />, + ); +} + +/** + * react-select با placeholder به‌عنوان aria-label رندر می‌شود و منو با ArrowDown باز + * می‌شود. انتخاب با role=option انجام می‌شود چون متنِ گزینه در live-region هم تکرار است. + */ +async function pick(selectLabel: string, optionText: string) { + fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: optionText })); +} + +describe('ConfirmAppointmentModal — انتخاب بیمه', () => { + it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => { + mockInsurance(BOTH); + renderReference(); + + expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); + expect(screen.getByText('بیمه')).toBeInTheDocument(); + }); + + it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => { + mockInsurance([ + { key: 'outpatient', label: 'خدمات سرپایی', enabled: true }, + { key: 'inpatient', label: 'خدمات بستری', enabled: false }, + ]); + renderReference(); + + expect(await screen.findByText('بیمه')).toBeInTheDocument(); + expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); + }); + + it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => { + mockInsurance(BOTH); + renderReference(); + + await screen.findByText('نوع خدمت'); + expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument(); + expect(screen.queryByText(/سهم بیمه/)).not.toBeInTheDocument(); + }); + + it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => { + mockInsurance(BOTH); + renderReference(); + + await screen.findByText('نوع خدمت'); + await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); + await pick('بدون بیمه', 'بیمه ایران'); + + // ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار + expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument(); + expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰'); + + fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })); + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', { + version: 1, + insurance_service_category: 'outpatient', + insurance_base_id: 3, + payments: [{ method: 'cash', amount_rials: 1_785_600 }], + })); + }); + + it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => { + mockInsurance(BOTH, 5_952_000); + renderWithProviders( + {}} + />, + ); + + // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد. + await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰')); + expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument(); + }); + + it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => { + mockInsurance(BOTH, 9_000_000); + renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد + + await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰')); + }); + + it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => { + mockInsurance(BOTH); + renderReference(); + + await screen.findByText('نوع خدمت'); + await pick('انتخاب نوع خدمت', 'خدمات بستری'); + await pick('بدون بیمه', 'بیمه ایران'); + + // ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار + await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰')); + }); +}); diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index b6154bd1..9b564a49 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -6,6 +6,8 @@ import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import type { BankAccount, Pos } from '../../hooks/usePaymentMethods'; import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; +import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares'; +import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance'; import Modal from '../ui/Modal'; import PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; @@ -22,6 +24,8 @@ interface ServiceItem { uuid: string; name: string; price_rials?: number | null; + service_category?: string | null; + insurance_covered?: boolean; } interface AppointmentLike { @@ -30,6 +34,8 @@ interface AppointmentLike { visit_price_rials?: number | null; service_items?: ServiceItem[] | null; patient_name?: string | null; + insurance_service_category?: string | null; + insurance_base_id?: number | null; } interface Props { @@ -128,27 +134,55 @@ export default function ConfirmAppointmentModal({ const appt: AppointmentLike | null = appointment ?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null); - const visitPrice = Number(appt?.visit_price_rials ?? 0); + // ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ────────────────────────────────────── + const insurance = useAppointmentInsurance(open); + + // نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛ + // مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند. + const visitPrice = insurance.visitPriceOf(appt?.visit_price_rials); const services = appt?.service_items ?? []; const servicesTotal = useMemo( () => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0), [services], ); const total = visitPrice + servicesTotal; + const [serviceCategory, setServiceCategory] = useState(''); + const [insuranceId, setInsuranceId] = useState(''); + + // مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant. + useEffect(() => { + if (!open) return; + setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? ''); + setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : ''); + }, [open, appt?.uuid, insurance.defaultCategory]); + + const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; + + // آینهٔ سرور: ویزیت با نوع انتخابی، هر خدمت با نوع خودش. + const shares = useMemo(() => insurance.breakdown([ + { total: visitPrice, category: effectiveCategory, insured: true }, + ...services.map(s => ({ + total: Number(s.price_rials ?? 0), + category: s.service_category ?? DEFAULT_SERVICE_CATEGORY, + insured: s.insurance_covered !== false, + })), + ], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]); + + const payable = insuranceId ? shares.patient : total; // ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛ - // نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر کل هزینه است. + // نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است. useEffect(() => { - if (!open || touched || total <= 0) return; - setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r))); - }, [open, touched, total]); + if (!open || touched || payable <= 0) return; + setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(payable) } : r))); + }, [open, touched, payable]); const paidRials = useMemo( () => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0), [rows], ); - const remaining = Math.max(0, total - paidRials); - const overpaid = paidRials > total; + const remaining = Math.max(0, payable - paidRials); + const overpaid = paidRials > payable; const paymentState = paidRials === 0 ? 'بدون پرداخت' @@ -160,6 +194,8 @@ export default function ConfirmAppointmentModal({ mutationFn: () => api.post>(`/api/v1/appointment/${appointmentUuid}/confirm`, { version: appt?.version, + ...(serviceCategory ? { insurance_service_category: serviceCategory } : {}), + ...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}), payments: rows .filter(r => tomanToRial(r.amountToman) > 0) .map(r => ({ @@ -254,6 +290,34 @@ export default function ConfirmAppointmentModal({
)} + {/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */} +
+ {insurance.needsCategoryChoice && ( +
+ + setServiceCategory(v == null ? '' : String(v))} + options={insurance.categoryOptions} + placeholder="انتخاب نوع خدمت" + height={40} + /> +
+ )} +
+ + setInsuranceId(v == null ? '' : String(v))} + options={insurance.insuranceOptions} + placeholder="بدون بیمه" + noOptionsMessage="قرارداد بیمهٔ فعالی ندارید" + isClearable + height={40} + /> +
+
+ {/* هزینه‌ها */}
{formatRial(Number(s.price_rials ?? 0))}
))} +
+ جمع کل + {formatRial(total)} +
+ {insuranceId !== '' && ( +
+ سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''} + {formatRial(shares.insurance)} +
+ )}
- جمع کل - {formatRial(total)} + {insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'} + {formatRial(payable)}
@@ -397,7 +471,7 @@ export default function ConfirmAppointmentModal({ {overpaid && (

- مجموع پرداخت‌ها از جمع کل بیشتر است. + مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.

)} @@ -410,7 +484,7 @@ export default function ConfirmAppointmentModal({ >
پرداخت‌شده - {formatRial(Math.min(paidRials, total))} + {formatRial(Math.min(paidRials, payable))}
باقی‌مانده diff --git a/assets/admin/components/appointments/TurnsTimeline.test.tsx b/assets/admin/components/appointments/TurnsTimeline.test.tsx index e30d1ddc..5684f85a 100644 --- a/assets/admin/components/appointments/TurnsTimeline.test.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.test.tsx @@ -7,6 +7,7 @@ vi.mock('../../lib/api', () => ({ ApiError: class extends Error {}, })); +import { api } from '../../lib/api'; import TurnsTimeline from './TurnsTimeline'; import type { TimelineSlot } from './types'; import type { Appointment } from '../../types'; @@ -17,6 +18,7 @@ const appt = (over: Partial = {}): Appointment => ({ slot_start: 1000, slot_end: 2000, appointment_date: '2024-12-31', appointment_time: '08:00', end_time: '08:35', status: 'completed', version: 1, created_at: '', service_item: { uuid: 's1', name: 'ویزیت عمومی' }, + ...over, } as unknown as Appointment); const occupiedSlot: TimelineSlot = { @@ -67,4 +69,33 @@ describe('TurnsTimeline', () => { expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument(); expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull(); }); + + it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => { + (api.get as ReturnType).mockImplementation((url: string) => + 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, + }] } }) + : Promise.resolve({ success: true, data: [] }), + ); + + const insured: TimelineSlot = { + ...occupiedSlot, + appointment: appt({ + insurance_base_id: 3, + insurance_service_category: 'inpatient', + insurance_service_category_label: 'خدمات بستری', + }), + }; + + renderWithProviders(); + + expect(await screen.findByText('خدمات بستری · بیمه ایران')).toBeInTheDocument(); + }); + + it('نوبتِ بدون بیمه چیپی نشان نمی‌دهد', () => { + renderWithProviders(); + expect(screen.queryByText(/بیمه ایران/)).toBeNull(); + }); }); diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index edcf6ccb..94345ec4 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon, ShieldCheckIcon } from '@heroicons/react/24/outline'; import type { Appointment } from '../../types'; +import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance'; import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown'; import AppointmentActionsMenu from '../AppointmentActions'; import ConfirmAppointmentModal from './ConfirmAppointmentModal'; @@ -105,6 +106,13 @@ function OccupiedCard({ }) { const cfg = turnStatusConfig(a.status); const [confirmOpen, setConfirmOpen] = useState(false); + // نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد + // تا لیست‌های نوبت به N+1 نیفتند. + const insurance = useAppointmentInsurance(!!a.insurance_base_id); + const insuranceChip = [ + a.insurance_service_category_label, + insurance.insuranceNameOf(a.insurance_base_id), + ].filter(Boolean).join(' · '); return (
onView(a)} @@ -133,6 +141,18 @@ function OccupiedCard({ سرویس: {a.service_item?.name || '—'}
+ {insuranceChip !== '' && ( +
+ + + {insuranceChip} + +
+ )}
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */} @@ -145,6 +165,7 @@ function OccupiedCard({ setConfirmOpen(false)} queryKey={queryKey} /> diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx index b2366346..07a94104 100644 --- a/assets/admin/components/session/CreateStep.test.tsx +++ b/assets/admin/components/session/CreateStep.test.tsx @@ -112,3 +112,137 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => { expect(share).toBe(600_000); }); }); + +// ── بیمه: نوع خدمت در ثبت/ویرایش مراجعه ────────────────────────────────────── + +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, + category_coverages: { outpatient: 70, inpatient: 30 }, +}; + +/** پروفایلِ بیمار با بیمهٔ پایه، تا بلوک بیمه نمایش داده شود. */ +const insuredProfile = { basic_insurance_id: 3 } as never; + +function mockInsuranceEndpoints(enabled: string[]) { + const categories = [ + { key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.includes('outpatient') }, + { key: 'inpatient', label: 'خدمات بستری', enabled: enabled.includes('inpatient') }, + ]; + get.mockImplementation((url: string) => { + if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: { + free_visit_price_rials: 5_952_000, + require_visit_price: false, + service_categories: categories, + default_service_category: enabled.length === 1 ? enabled[0] : null, + } }); + if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } }); + if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [BASIC_CONTRACT] } }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +/** react-select: منو با ArrowDown باز می‌شود و گزینه با role=option انتخاب می‌شود. */ +async function pick(selectLabel: string, optionText: string) { + fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: optionText })); +} + +describe('CreateStep — نوع خدمت بیمه', () => { + it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => { + mockInsuranceEndpoints(['outpatient', 'inpatient']); + renderWithProviders( + {}} />, + ); + + expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); + }); + + it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => { + mockInsuranceEndpoints(['outpatient']); + renderWithProviders( + {}} />, + ); + + // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است) + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200')); + expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); + }); + + it('انتخاب نوع خدمت، درصد پوشش همان نوع را روی فرم می‌گذارد و ارسال می‌کند', async () => { + mockInsuranceEndpoints(['outpatient', 'inpatient']); + renderWithProviders( + {}} />, + ); + await screen.findByText('نوع خدمت'); + + await pick('بدون بیمه پایه', 'بیمه ایران'); + await pick('انتخاب نوع خدمت', 'خدمات بستری'); + + // درصد بستری = ۳۰ + await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30')); + + fireEvent.click(screen.getByText('ایجاد سرویس')); + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][1]).toMatchObject({ + insurance_base_id: 3, + insurance_service_category: 'inpatient', + base_insurance_discount_percent: 30, + }); + }); +}); + +describe('CreateStep — نمایش و پیش‌انتخاب بیمه', () => { + it('با داشتن قرارداد بیمه، بلوک بیمه نمایش داده می‌شود (بدون بیمهٔ پروفایل)', async () => { + mockInsuranceEndpoints(['outpatient', 'inpatient']); + renderWithProviders( + {}} />, + ); + + expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); + expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument(); + }); + + it('بدون هیچ قراردادی، بلوک بیمه نمایش داده نمی‌شود', async () => { + mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false }); + renderWithProviders( + {}} />, + ); + + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000')); + expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument(); + }); + + it('مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود', async () => { + mockInsuranceEndpoints(['outpatient']); + renderWithProviders( + {}} />, + ); + + // بیمهٔ پروفایل (id=3) قرارداد فعال دارد → انتخاب و درصد سرپایی ۷۰ + expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70')); + }); + + it('ویرایش مراجعه: بیمهٔ ثبت‌شده مشخص و قابل تغییر است', async () => { + mockInsuranceEndpoints(['outpatient', 'inpatient']); + const editSession = { + uuid: 's-9', visit_price_rials: 5_952_000, session_at: 1_700_000_000, + insurance_base_id: 3, base_insurance_discount_percent: 30, + insurance_service_category: 'inpatient', + services: [], consumables: [], + } as never; + + renderWithProviders( + {}} />, + ); + + expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); + expect(screen.getByText('خدمات بستری')).toBeInTheDocument(); + expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30'); + + // قابل تغییر: انتخاب نوع سرپایی درصد را به ۷۰ می‌برد + await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); + await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70')); + }); +}); diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index da1e8d1a..dcdbaf18 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -11,37 +11,23 @@ import PersianDateInput from '../ui/PersianDateInput'; import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons'; import { useAuthStore } from '../../stores/authStore'; import { digitsOnly } from '../../lib/utils'; +import { + DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf, + type CoverageRule as Rule, type TenantContract, +} from '../../lib/insuranceShares'; -interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null; category_coverages?: Record } +interface 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 Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null } interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string } interface PackageRow { uuid: string; title: string; total: number; available: boolean } interface StaffRow { uuid: string; full_name: string; active?: boolean } /** ویزیت خدمتِ سرپایی است. */ -const VISIT_SERVICE_CATEGORY = 'outpatient'; +const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY; -/** - * آینه‌ی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل. - * فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است. - */ -export function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number { - let baseShare = 0; - let remaining = total; - if (base && base.covered) { - baseShare = Math.round(total * (base.percent / 100)); - if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling); - remaining = total - baseShare; - } - let suppShare = 0; - if (supp && supp.covered) { - suppShare = Math.round(remaining * (supp.percent / 100)); - if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling); - remaining = remaining - suppShare; - } - return Math.min(remaining + (supp?.franchise ?? 0), total); -} +// آینه‌ی BillingCalculator در lib/insuranceShares است؛ اینجا فقط re-export می‌شود تا +// مصرف‌کنندگان قبلی (و تست‌ها) نشکنند. +export { patientShareOf }; const todayISO = () => new Date().toISOString().slice(0, 10); const nowHHMM = () => { @@ -90,6 +76,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const [suppId, setSuppId] = useState(''); const [basePercent, setBasePercent] = useState('0'); const [suppPercent, setSuppPercent] = useState('0'); + /** نوع خدمتِ بیمه‌ایِ این مراجعه؛ خالی یعنی «پیش‌فرضِ tenant». */ + const [serviceCategory, setServiceCategory] = useState(''); const [notes, setNotes] = useState(''); // ── داده‌ها ────────────────────────────────────────────────────────────── @@ -124,6 +112,13 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false; + // نوع خدماتِ بیمه‌ایِ فعالِ این tenant — همان تنظیم سراسری «مدیریت بیمه». + const enabledCategories: { key: string; label: string }[] = + ((pricingData as any)?.data?.service_categories ?? []).filter((c: any) => c.enabled); + const needsCategoryChoice = enabledCategories.length > 1; + const defaultCategory: string = + (pricingData as any)?.data?.default_service_category ?? enabledCategories[0]?.key ?? VISIT_SERVICE_CATEGORY; + useEffect(() => { if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit))); }, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps @@ -141,6 +136,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e setNotes((editSession as any).notes ?? ''); if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); } if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); } + setServiceCategory(editSession.insurance_service_category ?? ''); setSelectedServices((editSession.services ?? []).map((s) => ({ uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false, category: VISIT_SERVICE_CATEGORY, @@ -172,6 +168,21 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null; const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null; + /** + * مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود — فقط اگر برای همان بیمه + * قرارداد فعال وجود داشته باشد. یک‌بار، و بعدش انتخاب کاربر دست‌نخورده می‌ماند. + */ + const [insurancePrefilled, setInsurancePrefilled] = useState(false); + useEffect(() => { + if (isEdit || insurancePrefilled || contracts.length === 0) return; + + const profileBase = profile?.basic_insurance_id ? String(profile.basic_insurance_id) : ''; + const profileSupp = profile?.supplementary_insurance_id ? String(profile.supplementary_insurance_id) : ''; + if (profileBase && baseOpts.some(o => o.value === profileBase)) applyBase(profileBase); + if (profileSupp && suppOpts.some(o => o.value === profileSupp)) applySupp(profileSupp); + setInsurancePrefilled(true); + }, [contracts.length, profile, isEdit, insurancePrefilled]); // eslint-disable-line react-hooks/exhaustive-deps + const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({ queryKey: ['service-coverage', baseContract?.uuid], queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`), @@ -185,10 +196,6 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? []; const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? []; - /** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */ - const contractPercent = (contract: Contract, category: string): number => - Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0); - /** * قاعده‌ی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد * همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده می‌شود. @@ -200,21 +207,39 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const isSupplementary = contract.insurance_kind === 'supplementary'; return { covered: true, - percent: ov?.coverage_percent ?? contractPercent(contract, category), + percent: ov?.coverage_percent ?? contractPercentFor(contract, category), franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0, ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials, }; }; - const coverageOf = (id: string): number => { + /** نوع خدمتِ مؤثرِ ویزیت: انتخاب کاربر، وگرنه تنها نوع فعالِ tenant. */ + const visitCategory = serviceCategory || defaultCategory; + + const coverageOf = (id: string, category = visitCategory): number => { const contract = contracts.find(c => String(c.insurance_id) === id); - return contract ? contractPercent(contract, VISIT_SERVICE_CATEGORY) : 0; + return contract ? contractPercentFor(contract, category) : 0; }; const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); }; const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); }; - // نمایش شرطی بلوک بیمه: سرویسِ تحت پوشش بیمه انتخاب شده یا پروفایل بیمار بیمه دارد. - const showInsurance = selectedServices.some(s => s.insured) + /** تغییر نوع خدمت، درصدهای ویزیت را با همان نوع همگام می‌کند. */ + const applyServiceCategory = (category: string) => { + setServiceCategory(category); + if (baseId) setBasePercent(String(coverageOf(baseId, category || defaultCategory))); + if (suppId) setSuppPercent(String(coverageOf(suppId, category || defaultCategory))); + }; + + /** + * بلوک بیمه هر وقت این پزشک/کلینیک قرارداد بیمهٔ فعال دارد نمایش داده می‌شود تا + * بیمه قابل انتخاب و تغییر باشد؛ پیش‌تر تنها با سرویسِ تحت‌پوشش یا بیمهٔ پروفایل + * ظاهر می‌شد و کاربر راهی برای انتخاب بیمه نداشت. مراجعه‌ای که بیمه دارد هم + * (حالت ویرایش) همیشه بلوک را نشان می‌دهد. + */ + const showInsurance = contracts.length > 0 + || !!baseId + || !!suppId + || selectedServices.some(s => s.insured) || !!profile?.basic_insurance_id || !!profile?.supplementary_insurance_id; @@ -257,9 +282,12 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e const servicesPatient = useMemo( () => selectedServices.reduce((sum, x) => { const total = x.price * x.qty; - if (!x.insured) return sum + total; - // نوع خدمت از کاتالوگ خوانده می‌شود تا ردیف‌های پیش‌پرشدهٔ ویرایش هم درست باشند. - const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category; + // نوع خدمت و پرچم پوشش از کاتالوگ خوانده می‌شوند تا ردیف‌های پیش‌پرشدهٔ ویرایش + // هم مثل سرور حساب شوند (هنگام prefill این دو را نداریم). + const catalogItem = serviceItems.find(i => i.uuid === x.uuid); + const insured = catalogItem?.insurance_covered ?? x.insured; + if (!insured) return sum + total; + const category = catalogItem?.service_category ?? x.category; return sum + patientShareOf( total, ruleFor(baseContract, baseCoverage, x.uuid, category), @@ -297,6 +325,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e supplementary_discount_percent: showInsurance ? supp : 0, ...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}), ...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}), + // نوع خدمتِ ویزیت؛ سرور درصد پوشش را بر پایهٔ همین resolve می‌کند. + insurance_service_category: showInsurance && (baseId || suppId) ? visitCategory : null, ...(isEdit ? {} : { payment_method: 'pending' }), ...(notes ? { notes } : {}), session_at: toSessionAt(dateISO, time), @@ -453,6 +483,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e {showInsurance && (
بیمه + {/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود؛ وگرنه همان نوعِ فعال. */} + {needsCategoryChoice && ( +
+ نوع خدمت + ({ value: c.key, label: c.label }))} + value={serviceCategory || null} + onChange={v => applyServiceCategory(v ? String(v) : '')} + placeholder="انتخاب نوع خدمت" + isClearable + /> +
+ )}
بیمه پایه diff --git a/assets/admin/hooks/useAppointmentInsurance.ts b/assets/admin/hooks/useAppointmentInsurance.ts new file mode 100644 index 00000000..25573854 --- /dev/null +++ b/assets/admin/hooks/useAppointmentInsurance.ts @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { breakdownOf, type BillableLine, type ShareBreakdown, type TenantContract } from '../lib/insuranceShares'; + +interface ServiceCategoryRow { + key: string; + label: string; + enabled: boolean; +} + +interface PricingPayload { + service_categories?: ServiceCategoryRow[]; + default_service_category?: string | null; + /** «قیمت ویزیت آزاد» tenant — سرور وقتی نوبت قیمت ندارد همین را می‌گذارد. */ + free_visit_price_rials?: number; +} + +/** + * انتخاب بیمهٔ یک نوبت: نوع خدماتِ فعالِ همین پزشک/کلینیک و قراردادهای بیمهٔ پایهٔ فعال، + * به‌همراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا + * هر دو یک قاعده را نشان دهند. + */ +export function useAppointmentInsurance(enabled: boolean) { + const pricingQuery = useQuery>({ + queryKey: ['insurance-pricing'], + queryFn: () => api.get('/api/v1/insurance-pricing'), + enabled, + }); + + const contractsQuery = useQuery>({ + queryKey: ['tenant-insurances'], + queryFn: () => api.get('/api/v1/billing/tenant-insurances'), + enabled, + }); + + const pricing = (pricingQuery.data?.data ?? {}) as PricingPayload; + + const enabledCategories = useMemo( + () => (pricing.service_categories ?? []).filter((c) => c.enabled), + [pricing.service_categories], + ); + + const contracts: TenantContract[] = useMemo( + () => ((contractsQuery.data?.data as any)?.data ?? []) as TenantContract[], + [contractsQuery.data], + ); + + const basicContracts = useMemo( + () => contracts.filter((c) => c.is_active !== false && c.insurance_kind !== 'supplementary'), + [contracts], + ); + + return { + /** + * هزینهٔ ویزیتِ مؤثر — آینهٔ همان fallback سرور: قیمت خودِ نوبت، در نبودش + * «قیمت ویزیت آزاد» تنظیمات همین tenant. + */ + visitPriceOf: (appointmentVisitPrice: number | null | undefined): number => + Number(appointmentVisitPrice ?? 0) > 0 + ? Number(appointmentVisitPrice) + : Number(pricing.free_visit_price_rials ?? 0), + + /** فقط وقتی بیش از یک نوع فعال است، انتخاب از کاربر پرسیده می‌شود. */ + needsCategoryChoice: enabledCategories.length > 1, + categoryOptions: enabledCategories.map((c) => ({ value: c.key, label: c.label })), + /** تنها نوع فعال (یا null اگر چند نوع فعال باشد) — همان قاعدهٔ سرور. */ + defaultCategory: pricing.default_service_category ?? enabledCategories[0]?.key ?? null, + categoryLabelOf: (key: string | null | undefined) => + (pricing.service_categories ?? []).find((c) => c.key === key)?.label ?? null, + + insuranceOptions: basicContracts.map((c) => ({ + value: String(c.insurance_id), + label: c.insurance_name ?? `#${c.insurance_id}`, + })), + contractOf: (insuranceId: string | number | null | undefined): TenantContract | null => + basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null, + insuranceNameOf: (insuranceId: string | number | null | undefined): string | null => + contracts.find((c) => String(c.insurance_id) === String(insuranceId))?.insurance_name ?? null, + + /** تفکیک سهم بیمه/بیمار برای ردیف‌های داده‌شده تحت قرارداد یک بیمه. */ + breakdown: (lines: BillableLine[], insuranceId: string | number | null | undefined): ShareBreakdown => + breakdownOf(lines, basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null), + + isLoading: pricingQuery.isLoading || contractsQuery.isLoading, + }; +} diff --git a/assets/admin/hooks/useServiceCategories.test.tsx b/assets/admin/hooks/useServiceCategories.test.tsx new file mode 100644 index 00000000..80738b2b --- /dev/null +++ b/assets/admin/hooks/useServiceCategories.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { useServiceCategories } from './useServiceCategories'; + +const get = api.get as ReturnType; + +function Probe() { + const { categories } = useServiceCategories(); + + return
{categories.map((c) => c.label).join('|')}
; +} + +beforeEach(() => get.mockReset()); + +describe('useServiceCategories', () => { + /** کنترلر با success(['data' => …]) پاسخ می‌دهد، پس آرایه یک لایه تودرتوست. */ + it('آرایه را از پاسخ تودرتوی کنترلر بیرون می‌کشد', async () => { + get.mockResolvedValue({ success: true, data: { data: [ + { key: 'outpatient', label: 'خدمات سرپایی' }, + { key: 'inpatient', label: 'خدمات بستری' }, + ] } }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent('خدمات سرپایی|خدمات بستری')); + }); + + it('پاسخ تخت (بدون لایهٔ اضافه) هم پذیرفته می‌شود', async () => { + get.mockResolvedValue({ success: true, data: [{ key: 'outpatient', label: 'خدمات سرپایی' }] }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent('خدمات سرپایی')); + }); + + it('پاسخ نامعتبر → لیست خالی، بدون خطا', async () => { + get.mockResolvedValue({ success: true, data: { insurances: [] } }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent('')); + }); +}); diff --git a/assets/admin/hooks/useServiceCategories.ts b/assets/admin/hooks/useServiceCategories.ts index 7a2cdfb9..41135605 100644 --- a/assets/admin/hooks/useServiceCategories.ts +++ b/assets/admin/hooks/useServiceCategories.ts @@ -19,7 +19,8 @@ export function useServiceCategories(enabled = true) { enabled, }); - const rows = data?.data; + // پاسخ کنترلر تودرتوست: success(['data' => …]) → data.data + const rows = (data as any)?.data?.data ?? data?.data; return { categories: Array.isArray(rows) ? rows : [], isLoading }; } diff --git a/assets/admin/lib/insuranceShares.ts b/assets/admin/lib/insuranceShares.ts new file mode 100644 index 00000000..f21ddaea --- /dev/null +++ b/assets/admin/lib/insuranceShares.ts @@ -0,0 +1,97 @@ +/** + * آینهٔ سمت‌کلاینتِ `BillingCalculator` سرور. تنها منبع محاسبهٔ سهم‌ها در پنل است — + * هیچ صفحه‌ای نباید فرمول درصدی جداگانه بنویسد، وگرنه مبلغِ نمایش‌داده‌شده با مبلغِ + * ثبت‌شده واگرا می‌شود. + */ + +export interface CoverageRule { + covered: boolean; + percent: number; + /** فقط در بیمهٔ تکمیلی معنا دارد. */ + franchise: number; + ceiling: number | null; +} + +/** قراردادِ بیمهٔ tenant، همان شکلی که `/api/v1/billing/tenant-insurances` می‌دهد. */ +export interface TenantContract { + insurance_id: number; + insurance_name: string | null; + insurance_kind: string | null; + is_active?: boolean; + coverage_percent: number; + franchise_rials: number; + annual_ceiling_rials: number | null; + category_coverages?: Record; +} + +/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */ +export const DEFAULT_SERVICE_CATEGORY = 'outpatient'; + +/** + * سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی (روی باقیمانده) + فرانشیزِ تکمیلی. + * فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند. + */ +export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number { + let baseShare = 0; + let remaining = total; + if (base && base.covered) { + baseShare = Math.round(total * (base.percent / 100)); + if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling); + remaining = total - baseShare; + } + let suppShare = 0; + if (supp && supp.covered) { + suppShare = Math.round(remaining * (supp.percent / 100)); + if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling); + remaining = remaining - suppShare; + } + + return Math.min(remaining + (supp?.franchise ?? 0), total); +} + +/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */ +export function contractPercentFor(contract: TenantContract, category: string): number { + return Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0); +} + +/** قاعدهٔ پوشش یک ردیف تحت یک قرارداد. فرانشیز فقط از قرارداد تکمیلی خوانده می‌شود. */ +export function ruleOf(contract: TenantContract | null, category: string): CoverageRule | null { + if (!contract) return null; + + return { + covered: true, + percent: contractPercentFor(contract, category), + franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_rials : 0, + ceiling: contract.annual_ceiling_rials, + }; +} + +export interface BillableLine { + /** مبلغ کل ردیف (با احتساب تعداد). */ + total: number; + /** نوع خدمت؛ برای ویزیت نوعِ انتخاب‌شدهٔ مراجعه. */ + category: string; + /** خدمتی که پوشش بیمه ندارد کامل سهم بیمار است. */ + insured: boolean; +} + +export interface ShareBreakdown { + total: number; + insurance: number; + patient: number; +} + +/** تفکیک سهم بیمه/بیمار برای مجموعه‌ای از ردیف‌ها تحت یک قرارداد پایه. */ +export function breakdownOf(lines: BillableLine[], base: TenantContract | null): ShareBreakdown { + return lines.reduce((acc, line) => { + const patient = line.insured + ? patientShareOf(line.total, ruleOf(base, line.category), null) + : line.total; + + return { + total: acc.total + line.total, + insurance: acc.insurance + (line.total - patient), + patient: acc.patient + patient, + }; + }, { total: 0, insurance: 0, patient: 0 }); +} diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx index 8699b949..2ec6d809 100644 --- a/assets/admin/pages/AppointmentEditPage.test.tsx +++ b/assets/admin/pages/AppointmentEditPage.test.tsx @@ -65,3 +65,68 @@ 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, + category_coverages: { outpatient: 70, inpatient: 30 }, +}; + +/** همان mock بالا + payload بیمه؛ `enabled` تعیین می‌کند چند نوع خدمت فعال است. */ +function mockWithInsurance(enabledCategories: string[]) { + const categories = [ + { key: 'outpatient', label: 'خدمات سرپایی', enabled: enabledCategories.includes('outpatient') }, + { key: 'inpatient', label: 'خدمات بستری', enabled: enabledCategories.includes('inpatient') }, + ]; + get.mockImplementation((url: string) => { + if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: { + uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4, + visit_price_rials: 5_952_000, service_items: [], + insurance_service_category: 'inpatient', insurance_base_id: 3, + } } }); + if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: { + service_categories: categories, + default_service_category: enabledCategories.length === 1 ? enabledCategories[0] : null, + } }); + if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [CONTRACT] } }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +describe('AppointmentEditPage — بیمه', () => { + it('نوع خدمت و بیمه از نوبت پیش‌پر می‌شوند و سهم‌ها نمایش داده می‌شوند', async () => { + mockWithInsurance(['outpatient', 'inpatient']); + renderEdit(); + + expect(await screen.findByText('بیمه:')).toBeInTheDocument(); + expect(screen.getByText('نوع خدمت')).toBeInTheDocument(); + expect(screen.getByText('خدمات بستری')).toBeInTheDocument(); + expect(screen.getByText('بیمه ایران')).toBeInTheDocument(); + // ۵٬۹۵۲٬۰۰۰ × ۳۰٪ → سهم بیمه ۱٬۷۸۵٬۶۰۰ و سهم بیمار ۴٬۱۶۶٬۴۰۰ + expect(screen.getByText('سهم بیمه / سهم بیمار')).toBeInTheDocument(); + }); + + it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => { + mockWithInsurance(['outpatient']); + renderEdit(); + + expect(await screen.findByText('بیمه:')).toBeInTheDocument(); + expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); + }); + + it('PATCH فیلدهای بیمه را می‌فرستد', async () => { + mockWithInsurance(['outpatient', 'inpatient']); + renderEdit(); + // تا فرم از نوبت پر نشود دکمه غیرفعال است؛ همان را معیار آماده‌بودن می‌گیریم. + await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).not.toBeDisabled()); + + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + insurance_service_category: 'inpatient', + insurance_base_id: 3, + }))); + }); +}); diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx index ccfb8639..fdc6ff74 100644 --- a/assets/admin/pages/AppointmentEditPage.tsx +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -9,7 +9,9 @@ import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; -import { rialToToman, tomanToRial } from '../lib/utils'; +import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; +import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares'; +import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance'; interface Option { uuid: string; name?: string; full_name?: string } @@ -20,6 +22,10 @@ interface AppointmentDetail { user?: { uuid: string; mobile: string } | null; deposit_required?: boolean; deposit_amount_rials?: number | null; service_section?: Option | null; service_item?: Option | null; staff?: Option | null; + visit_price_rials?: number | null; + service_items?: { uuid: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; + insurance_service_category?: string | null; + insurance_base_id?: number | null; } const isoDate = (ts: number) => { @@ -55,6 +61,8 @@ export default function AppointmentEditPage() { const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState(''); const [note, setNote] = useState(''); + const [serviceCategory, setServiceCategory] = useState(''); + const [insuranceId, setInsuranceId] = useState(''); // hydrate once the appointment arrives useEffect(() => { @@ -69,8 +77,25 @@ export default function AppointmentEditPage() { setDepositToman(rialToToman(a.deposit_amount_rials ?? 0)); setStatus(a.status); setNote(a.note ?? ''); + setServiceCategory(a.insurance_service_category ?? ''); + setInsuranceId(a.insurance_base_id ? String(a.insurance_base_id) : ''); }, [a]); + const insurance = useAppointmentInsurance(!!uuid); + + // نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور). + const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; + + const shares = insurance.breakdown([ + // نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را می‌گیرد (مثل سرور). + { total: insurance.visitPriceOf(a?.visit_price_rials), category: effectiveCategory, insured: true }, + ...(a?.service_items ?? []).map((s) => ({ + total: Number(s.price_rials ?? 0), + category: s.service_category ?? DEFAULT_SERVICE_CATEGORY, + insured: s.insurance_covered !== false, + })), + ], insuranceId); + const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); const itemsQ = useQuery>({ queryKey: ['service-items', sectionUuid], @@ -89,6 +114,8 @@ export default function AppointmentEditPage() { deposit_required: depositRequired, deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, note, + insurance_service_category: serviceCategory || null, + insurance_base_id: insuranceId ? Number(insuranceId) : null, ...(status !== a?.status ? { status } : {}), version: a?.version, }), @@ -166,6 +193,50 @@ export default function AppointmentEditPage() {
+
بیمه:
+
+ {/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */} + {insurance.needsCategoryChoice && ( +
+ +
+ setServiceCategory(v ? String(v) : '')} + placeholder="انتخاب نوع خدمت" + isClearable + height={38} + /> +
+
+ )} +
+ +
+ setInsuranceId(v ? String(v) : '')} + placeholder="بدون بیمه" + noOptionsMessage="قرارداد بیمهٔ فعالی ندارید" + isClearable + height={38} + /> +
+
+ {insuranceId !== '' && ( +
+ +
+ {formatRial(shares.insurance)} + / + {formatRial(shares.patient)} +
+
+ )} +
+
زمان نوبت:
diff --git a/assets/admin/pages/BlogsPage.test.tsx b/assets/admin/pages/BlogsPage.test.tsx index 6d0a51bb..c9551b42 100644 --- a/assets/admin/pages/BlogsPage.test.tsx +++ b/assets/admin/pages/BlogsPage.test.tsx @@ -33,7 +33,8 @@ describe('BlogsPage — قرارداد PaginatedResponse', () => { expect(await screen.findByText('مقاله اول')).toBeInTheDocument(); expect(screen.getByText('مقاله دوم')).toBeInTheDocument(); - expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/blogs')); + // اندپوینت ادمین، نه عمومی: لیست پنل باید همهٔ وضعیت‌ها را بیاورد. + expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/admin/blogs')); }); it('پاسخ خالی → پیام «هیچ مقاله‌ای یافت نشد»', async () => { diff --git a/assets/admin/pages/InsurancePricingPage.tsx b/assets/admin/pages/InsurancePricingPage.tsx index df477a37..57fca9e8 100644 --- a/assets/admin/pages/InsurancePricingPage.tsx +++ b/assets/admin/pages/InsurancePricingPage.tsx @@ -1,4 +1,5 @@ import PageHeader from '../components/ui/PageHeader'; +import InsuranceServiceCategoriesCard from '../components/InsuranceServiceCategoriesCard'; import TenantInsuranceContracts from '../components/TenantInsuranceContracts'; import FeatureGate from '../components/ui/FeatureGate'; import SettingsLayout from '../components/layout/SettingsLayout'; @@ -12,6 +13,7 @@ export default function InsurancePricingPage() { title="مدیریت بیمه" description="قراردادهای بیمه پایه و تکمیلی" /> +
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index f84b0681..e3c9faa7 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -114,6 +114,12 @@ export interface Appointment { service_section?: { uuid: string; name: string } | null; service_item?: { uuid: string; name: string } | null; staff?: { uuid: string; full_name: string } | null; + visit_price_rials?: number | null; + service_items?: { uuid: string; name: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; + /** نوع خدمتِ بیمه‌ای و بیمهٔ پایهٔ انتخاب‌شده روی همین نوبت. */ + insurance_service_category?: string | null; + insurance_service_category_label?: string | null; + insurance_base_id?: number | null; } export interface AppointmentEvent { diff --git a/docs/api/appointment.md b/docs/api/appointment.md index d005283b..0dd01f30 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -351,12 +351,25 @@ Get appointment detail. "note": "...", "patient_name": "...", "patient_mobile": "...", + "insurance_service_category": "inpatient", + "insurance_service_category_label": "خدمات بستری", + "insurance_base_id": 3, "created_at": 1717000000 } } ``` > `doctor.specialties` آرایه (ممکن است خالی)؛ `address` اولین آدرس پزشک است (ممکن است `null` اگر پزشک آدرسی ندارد). `address.map.latitude/longitude` رشته یا `null`. تاریخ‌ها Unix. +### انتخاب بیمهٔ نوبت + +| فیلد | نوع | توضیح | +|------|-----|-------| +| `insurance_service_category` | string \| null | نوع خدمتِ بیمه‌ایِ این نوبت — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). `null` = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (`default_service_category` در [insurance.md](insurance.md)) | +| `insurance_service_category_label` | string \| null | برچسب فارسی همان نوع | +| `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد | + +نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند. + ### Errors | Code | HTTP | Description | |------|------|-------------| @@ -530,6 +543,8 @@ transaction**. If any step fails nothing is committed. ```json { "version": 3, + "insurance_service_category": "inpatient", + "insurance_base_id": 3, "payments": [ { "method": "pos", "amount_rials": 3000000, "payment_method_uuid": "…pos-uuid…", "reference": "TRX-42" }, { "method": "cash", "amount_rials": 2000000 } @@ -540,6 +555,8 @@ transaction**. If any step fails nothing is committed. | Field | Type | Required | Description | |-------|------|----------|-------------| | `version` | integer | ❌ | Optimistic lock version; defaults to the stored one | +| `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. | +| `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. | | `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). | | `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` | | `payments[].amount_rials` | integer | ✅ | > 0 | @@ -554,12 +571,24 @@ Partial payment is normal: the remainder stays as `remaining_rials` on the visit collected later through `POST /api/v1/session/{uuid}/payments`. ### What happens on the server -1. `pending → confirmed` (state machine still applies). -2. `AppointmentConfirmationService` files the case file for the appointment's environment +1. انتخاب بیمه (اگر در بدنه آمده باشد) روی نوبت می‌نشیند و اعتبارسنجی می‌شود. +2. `pending → confirmed` (state machine still applies). +3. `AppointmentConfirmationService` files the case file for the appointment's environment (`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing** record for that patient in that environment is reused, otherwise a new one is created. A `PatientSession` is opened with the visit price and one line per attached service. -3. Each payment row is registered on that visit (`wallet` also debits the patient wallet). +4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از + نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve + ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). نوبتِ بدون بیمه + مثل قبل کاملاً سهم بیمار می‌ماند. +5. Each payment row is registered on that visit (`wallet` also debits the patient wallet). + +> پیش از این، پرونده‌ای که با قطعی‌کردن ساخته می‌شد همیشه کل مبلغ را سهم بیمار می‌گذاشت +> (`applyShares($gross, 0, 0, $gross)`) و صفحهٔ پرداخت با فاکتور واگرا می‌شد. + +پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`، +`supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`، +`insurance_base_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است. ### Response `200` ```json @@ -834,12 +863,15 @@ General update (ویرایش / جا به جایی / انتقال به رزرو / "service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…", "deposit_required": true, "deposit_amount_rials": 5000000, "note": "…", "patient_name": "…", "patient_mobile": "…", + "insurance_service_category": "inpatient", "insurance_base_id": 3, "status": "confirmed", "version": 3 } ``` - `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`. - Relation uuids: empty string clears; unknown uuid → `422`. +- `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`. +- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال یا بیمهٔ تکمیلی → `422 ERR_VALIDATION_001` با فیلد `insurance_base_id`. - `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission. - Optimistic lock via `version` → `409` on concurrent edit. diff --git a/docs/api/billing.md b/docs/api/billing.md index b513d2e0..eca27930 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -10,7 +10,8 @@ - تعرفه‌ی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`). - قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`). -- درصد پوشش به تفکیک **نوع خدمت** (`ServiceItem.service_category`؛ ویزیت همیشه `outpatient`) و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود. +- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی). +- `invoices.service_category` همان نوع را snapshot می‌کند و در `toArray()` به‌صورت `service_category` / `service_category_label` برمی‌گردد. - ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار. **بیمهٔ پایه صرفاً درصدی است:** @@ -85,6 +86,8 @@ **Response 200:** همان ساختار بالا + کلید `session` — خروجی کامل `PatientSession.toArray()` مراجعه‌ای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختی‌ها، کالای مصرفی، تخفیف، مبالغ پرداخت‌شده). اگر صورتحساب از session ساخته نشده باشد `session: null`. +همچنین برای درج روی فاکتور: `service_category` / `service_category_label` (نوع خدمتِ بیمه‌ای) و `base_insurance_name` / `supplementary_insurance_name` (نام بیمه‌ها؛ فاکتور خودش فقط شناسه را نگه می‌دارد). + ```json { "success": true, diff --git a/docs/api/insurance.md b/docs/api/insurance.md index aefa1860..4e8c619b 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -431,6 +431,23 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR } ``` +همچنین دو کلید سراسریِ tenant: + +```json +{ + "service_categories": [ + { "key": "outpatient", "label": "خدمات سرپایی", "enabled": true }, + { "key": "inpatient", "label": "خدمات بستری", "enabled": false } + ], + "default_service_category": "outpatient" +} +``` + +| فیلد | توضیح | +|------|-------| +| `service_categories` | نوع خدماتی که بیمه‌های این پزشک/کلینیک پوشش می‌دهند — **سراسری برای همهٔ بیمه‌های همان tenant**، نه per-insurance. همیشه همهٔ نوع‌ها برمی‌گردند؛ نبودِ ردیف در DB = `enabled: true`. پنل: کارت «نوع خدمات بیمه» در تنظیمات ← مدیریت بیمه (`/admin/insurance-pricing`) | +| `default_service_category` | اگر فقط یک نوع فعال باشد همان (مبنای خودکار محاسبه)؛ اگر بیش از یکی فعال باشد `null` و پنل باید سرِ پذیرش بپرسد | + - `coverage_defaults` — درصدهای مرکزی ادمین؛ پنل پزشک هنگام افزودن قرارداد از همین پر می‌کند. - `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد). این مقدار **ورودی هیچ محاسبه‌ای نیست**؛ محاسبهٔ سهم فقط از درصد پوشش انجام می‌شود. - `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند. @@ -467,8 +484,10 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR | `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. | | `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). | | `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. | +| `service_categories` | array | اختیاری — `[{ "key": "inpatient", "enabled": false }]`. فقط نوع‌های ارسالی تغییر می‌کنند؛ نیامدنِ کلید یعنی تنظیمات دست‌نخورده. | اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود. +اعتبارسنجی `service_categories`: `key ∈ ServiceCategory::values()` و پس از اعمالِ تغییر **حداقل یک نوع فعال بماند** — وگرنه `422 ERR_VALIDATION_001` با فیلد `service_categories`. ### Response `200` همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره). @@ -476,6 +495,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR ### خطاها - `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد. - `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`. +- `422` `ERR_VALIDATION_001` (field: `service_categories`) — نوع خدمت نامعتبر، یا غیرفعال‌کردن همهٔ نوع‌ها. --- diff --git a/docs/api/patient.md b/docs/api/patient.md index 16f5332d..80885e95 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -504,6 +504,8 @@ Creates a new visit session for a patient record. - `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع). - `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیش‌فرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه می‌شود. آیتم ناموجود یا متعلق به tenant دیگر بی‌صدا رد می‌شود (هم‌رفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است. - `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیش‌فرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد. +- پاسخ‌های مراجعه (لیست، ایجاد، ویرایش) علاوه بر شناسه‌ها، `insurance_base_name` و `insurance_supplementary_name` را هم دارند (نام‌ها با کش درون‌درخواستی حل می‌شوند، بدون N+1) تا کارت «سرویس‌ها» در پرونده بیمار بتواند نوع خدمت و بیمه را نشان دهد. +- `insurance_service_category` (اختیاری): نوع خدمتِ بیمه‌ایِ این مراجعه — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). درصد پوششِ **ویزیت** از همین نوع resolve می‌شود؛ سهم هر خدمت همچنان از `service_category` خودِ خدمت می‌آید. `null` → سرپایی. در پاسخ به‌همراه `insurance_service_category_label` برمی‌گردد و در `PATCH /api/v1/session/{uuid}` هم پذیرفته می‌شود. - `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد مؤثر قرارداد فعال (زنجیرهٔ resolve — [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)، با نوع خدمتِ `outpatient` برای ویزیت) بازنویسی می‌شود؛ صرفاً snapshot برای نمایش/گزارش‌اند. - `final_price_rials` (سهم بیمار) به این صورت محاسبه می‌شود: - **ویزیت:** خدمتِ سرپایی است و با قاعده‌ی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` حساب می‌شود — همان مسیری که `InvoiceService` برای صدور فاکتور می‌رود. سهم بیمهٔ پایه = `round(کل × درصد ÷ 100)` و سهم بیمار = `کل − سهم پایه`؛ فرانشیزِ قرارداد پایه بی‌اثر است. @@ -687,6 +689,10 @@ GET /api/v1/session/{uuid}/audit-log 1. اگر بیمار در آن محیط پرونده نداشته باشد، یک `PatientRecord` می‌سازد 2. یک `PatientSession` گره‌خورده به همان نوبت می‌سازد (زمان مراجعه = زمان نوبت، هزینه ویزیت و خطوط سرویس از خود نوبت snapshot می‌شوند) +3. **بیمهٔ نوبت** (`appointments.insurance_base_id` + `insurance_service_category`) روی مراجعه + snapshot می‌شود و سهم بیمه/بیمار با `BillingCalculator` محاسبه می‌گردد. نوع خدمتِ مؤثر: + انتخابِ نوبت → تنها نوع فعالِ tenant → سرپایی. نوبتِ بدون بیمه کاملاً سهم بیمار می‌ماند + (رفتار قبلی). ### محیط پرونده — یکی، نه هر دو (2026-07) diff --git a/migrations/Version20260725131031.php b/migrations/Version20260725131031.php new file mode 100644 index 00000000..ac6431b7 --- /dev/null +++ b/migrations/Version20260725131031.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE tenant_service_category_settings (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_category VARCHAR(30) NOT NULL, enabled TINYINT DEFAULT 1 NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_tenant_service_category_setting (entity_type, entity_id, service_category), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE tenant_service_category_settings'); + } +} diff --git a/migrations/Version20260725131554.php b/migrations/Version20260725131554.php new file mode 100644 index 00000000..eb74ce7c --- /dev/null +++ b/migrations/Version20260725131554.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE appointments ADD insurance_service_category VARCHAR(30) DEFAULT NULL, ADD insurance_base_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointments DROP insurance_service_category, DROP insurance_base_id'); + } +} diff --git a/migrations/Version20260725132739.php b/migrations/Version20260725132739.php new file mode 100644 index 00000000..fb84f226 --- /dev/null +++ b/migrations/Version20260725132739.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE patient_sessions ADD insurance_service_category VARCHAR(30) DEFAULT NULL'); + $this->addSql('ALTER TABLE invoices ADD service_category VARCHAR(30) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE patient_sessions DROP insurance_service_category'); + $this->addSql('ALTER TABLE invoices DROP service_category'); + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index ea8c3c50..584a13fe 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -42,6 +42,7 @@ class AppointmentController extends BaseController private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo, private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo, private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker, + private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -1026,6 +1027,10 @@ class AppointmentController extends BaseController ]; } + // انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با + // همان بیمه محاسبه شوند. + $this->appointmentInsurance->apply($appointment, $data); + try { $session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user); } catch (OptimisticLockException) { @@ -1043,6 +1048,13 @@ class AppointmentController extends BaseController 'paid_total_rials' => $session->getPaidTotalRials(), 'remaining_rials' => $session->getRemainingRials(), 'is_paid' => $session->getRemainingRials() === 0, + // تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده. + 'insurance_service_category' => $session->getInsuranceServiceCategory()?->value, + 'insurance_base_id' => $session->getInsuranceBaseId(), + 'gross_total_rials' => $session->getGrossTotalRials(), + 'base_insurance_rials' => $session->getBaseInsuranceRials(), + 'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(), + 'patient_share_rials' => $session->getPatientShareRials(), ], ]); } @@ -1126,6 +1138,7 @@ class AppointmentController extends BaseController if (array_key_exists('note', $data)) { $appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null); } + $this->appointmentInsurance->apply($appointment, $data); // جایگزینی نوبت — swap the person occupying the slot. if (array_key_exists('patient_name', $data)) { $appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null); diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 6a8cd6ee..47f93fb8 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -4,6 +4,7 @@ namespace App\Appointment\Entity; use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; +use App\Insurance\Enum\ServiceCategory; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -171,6 +172,17 @@ class Appointment #[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)] private ?int $visitPriceRials = null; + /** + * نوع خدمتِ بیمه‌ایِ این نوبت (سرپایی/بستری) — مبنای انتخاب درصد پوشش. + * null یعنی هنوز انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد. + */ + #[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)] + private ?ServiceCategory $insuranceServiceCategory = null; + + /** بیمهٔ پایهٔ انتخاب‌شده؛ ارجاع خام int مثل TenantInsurance/Tariff. */ + #[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)] + private ?int $insuranceBaseId = null; + /** * Reserve-list entry (نوبت رزرو): booked for a day, not a time slot. * slotStart/slotEnd hold that day's midnight so date queries keep working. @@ -265,6 +277,8 @@ class Appointment public function isDepositRequired(): bool { return $this->depositRequired; } public function getDepositAmountRials(): ?int { return $this->depositAmountRials; } public function getVisitPriceRials(): ?int { return $this->visitPriceRials; } + public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; } + public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; } public function isReserve(): bool { return $this->isReserve; } public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; } @@ -273,6 +287,8 @@ class Appointment public function setDepositRequired(bool $v): self { $this->depositRequired = $v; return $this; } public function setDepositAmountRials(?int $v): self { $this->depositAmountRials = $v; return $this; } public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; } + public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; } + public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; } /** * Move the appointment to a new slot (جا به جایی نوبت) and/or flip its @@ -368,6 +384,10 @@ class Appointment 'uuid' => $i->getUuid(), 'name' => $i->getName(), 'price_rials' => $i->getPriceRials(), + // نوع خدمت و پرچم پوشش تا مودال بتواند سهم بیمهٔ هر خدمت را + // مثل سرور حساب کند (درصد به‌ازای نوع خدمت است). + 'service_category' => $i->getServiceCategory()->value, + 'insurance_covered' => $i->isInsuranceCovered(), ], $this->serviceItems->toArray() ), @@ -375,6 +395,9 @@ class Appointment 'deposit_required' => $this->depositRequired, 'deposit_amount_rials' => $this->depositAmountRials, 'visit_price_rials' => $this->visitPriceRials, + 'insurance_service_category' => $this->insuranceServiceCategory?->value, + 'insurance_service_category_label' => $this->insuranceServiceCategory?->label(), + 'insurance_base_id' => $this->insuranceBaseId, 'is_reserve' => $this->isReserve, 'version' => $this->version, 'created_at' => $this->createdAt, diff --git a/src/Appointment/Service/AppointmentInsuranceService.php b/src/Appointment/Service/AppointmentInsuranceService.php new file mode 100644 index 00000000..bc91fe0c --- /dev/null +++ b/src/Appointment/Service/AppointmentInsuranceService.php @@ -0,0 +1,139 @@ +getClinic(); + + return $clinic !== null + ? ['clinic', (int) $clinic->getId()] + : ['doctor', (int) $appointment->getDoctor()->getId()]; + } + + /** + * نوع خدمتِ مؤثر برای محاسبه: انتخابِ نوبت، وگرنه تنها نوع فعالِ tenant، + * وگرنه سرپایی (رفتار تاریخیِ ویزیت). + */ + public function effectiveCategory(Appointment $appointment): ServiceCategory + { + if ($appointment->getInsuranceServiceCategory() !== null) { + return $appointment->getInsuranceServiceCategory(); + } + + [$entityType, $entityId] = $this->tenantOf($appointment); + + return $this->serviceCategories->defaultCategory($entityType, $entityId) ?? ServiceCategory::Outpatient; + } + + /** + * انتخاب بیمهٔ نوبت را از بدنهٔ درخواست اعمال می‌کند. کلیدهای نیامده دست‌نخورده + * می‌مانند؛ رشتهٔ خالی یا null یعنی پاک‌کردن انتخاب. + * + * @param array $data + * @throws AppException ۴۲۲ برای نوع خدمتِ نامعتبر/غیرفعال یا بیمهٔ بدون قرارداد فعال + */ + public function apply(Appointment $appointment, array $data): void + { + [$entityType, $entityId] = $this->tenantOf($appointment); + + if (array_key_exists('insurance_service_category', $data)) { + $appointment->setInsuranceServiceCategory( + $this->resolveCategory($data['insurance_service_category'], $entityType, $entityId) + ); + } + + if (array_key_exists('insurance_base_id', $data)) { + $appointment->setInsuranceBaseId( + $this->resolveBaseInsuranceId($data['insurance_base_id'], $entityType, $entityId) + ); + } + } + + private function resolveCategory(mixed $raw, string $entityType, int $entityId): ?ServiceCategory + { + if ($raw === null || $raw === '') { + return null; + } + + $category = ServiceCategory::tryFromValue((string) $raw); + if ($category === null) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()), + 422, + 'insurance_service_category', + ); + } + + if (!$this->serviceCategories->isEnabled($entityType, $entityId, $category)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('«%s» در تنظیمات بیمه فعال نیست', $category->label()), + 422, + 'insurance_service_category', + ); + } + + return $category; + } + + private function resolveBaseInsuranceId(mixed $raw, string $entityType, int $entityId): ?int + { + if ($raw === null || $raw === '' || (int) $raw <= 0) { + return null; + } + + $insuranceId = (int) $raw; + $contract = $this->tenantInsuranceRepo->findActiveContract($entityType, $entityId, $insuranceId); + if ($contract === null) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد', + 422, + 'insurance_base_id', + ); + } + + // نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService. + $kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value; + if ($kind === InsuranceType::Supplementary->value) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'برای نوبت فقط بیمهٔ پایه قابل انتخاب است', + 422, + 'insurance_base_id', + ); + } + + return $insuranceId; + } +} diff --git a/src/Billing/Entity/Invoice.php b/src/Billing/Entity/Invoice.php index 200884ca..3ee9c104 100644 --- a/src/Billing/Entity/Invoice.php +++ b/src/Billing/Entity/Invoice.php @@ -3,6 +3,7 @@ namespace App\Billing\Entity; use App\Billing\Repository\InvoiceRepository; +use App\Insurance\Enum\ServiceCategory; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -45,6 +46,10 @@ class Invoice #[ORM\Column(name: 'supplementary_insurance_id', type: 'integer', nullable: true)] private ?int $supplementaryInsuranceId = null; + /** نوع خدمتِ بیمه‌ای که فاکتور با آن محاسبه شده — snapshot از مراجعه. */ + #[ORM\Column(name: 'service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)] + private ?ServiceCategory $serviceCategory = null; + #[ORM\Column(name: 'total_rials', type: 'integer')] private int $totalRials = 0; @@ -100,6 +105,8 @@ class Invoice public function setPatientSessionId(?int $v): self { $this->patientSessionId = $v; return $this; } public function setPatientRecordId(?int $v): self { $this->patientRecordId = $v; return $this; } public function setBaseInsuranceId(?int $v): self { $this->baseInsuranceId = $v; return $this; } + public function setServiceCategory(?ServiceCategory $v): self { $this->serviceCategory = $v; return $this; } + public function getServiceCategory(): ?ServiceCategory { return $this->serviceCategory; } public function setSupplementaryInsuranceId(?int $v): self { $this->supplementaryInsuranceId = $v; return $this; } public function addItem(InvoiceItem $item): self @@ -142,6 +149,8 @@ class Invoice 'patient_session_id' => $this->patientSessionId, 'patient_record_id' => $this->patientRecordId, 'base_insurance_id' => $this->baseInsuranceId, + 'service_category' => $this->serviceCategory?->value, + 'service_category_label' => $this->serviceCategory?->label(), 'supplementary_insurance_id' => $this->supplementaryInsuranceId, 'total_rials' => $this->totalRials, 'base_insurance_rials' => $this->baseInsuranceRials, diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 6b41cc2f..81adc0ce 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -7,6 +7,8 @@ use App\Billing\Entity\InvoiceItem; use App\Billing\Repository\InvoiceRepository; use App\Billing\ValueObject\Money; use App\ClinicService\Service\TariffService; +use App\Insurance\Enum\ServiceCategory; +use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Service\TenantInsuranceService; use App\Patient\Entity\PatientSession; use App\Patient\Repository\PatientSessionRepository; @@ -19,8 +21,21 @@ class InvoiceService private readonly TenantInsuranceService $tenantInsuranceService, private readonly BillingCalculator $calculator, private readonly PatientSessionRepository $sessionRepo, + private readonly InsuranceRepository $insuranceRepo, ) {} + /** @var array نام بیمه‌ها، یک‌بار در هر درخواست. */ + private array $insuranceNameCache = []; + + private function insuranceName(?int $id): ?string + { + if ($id === null) { + return null; + } + + return $this->insuranceNameCache[$id] ??= $this->insuranceRepo->find($id)?->getName(); + } + /** * ساخت Invoice از یک Encounter (PatientSession). * تعرفه‌ی هر خدمت از Tariff سال جاری (با fallback)، پوشش از قرارداد بیمه‌ی tenant. @@ -42,11 +57,14 @@ class InvoiceService $baseId = $session->getInsuranceBaseId(); $suppId = $session->getInsuranceSupplementaryId(); - // ویزیت + // ویزیت — با نوع خدمتِ همان مراجعه (سرپایی/بستری)، snapshot روی خودِ فاکتور. + $visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient; + $invoice->setServiceCategory($session->getInsuranceServiceCategory()); + $visitPrice = $session->getVisitPriceRials(); if ($visitPrice > 0) { - $baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId); - $suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId); + $baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId, $visitCategory); + $suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId, $visitCategory); $breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule); $invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null)); } @@ -90,6 +108,10 @@ class InvoiceService $session = $sessionId !== null ? $this->sessionRepo->find($sessionId) : null; $data['session'] = $session?->toArray(); + // نام بیمه‌ها برای چاپ روی فاکتور؛ فاکتور فقط شناسه را نگه می‌دارد. + $data['base_insurance_name'] = $this->insuranceName($invoice->getBaseInsuranceId()); + $data['supplementary_insurance_name'] = $this->insuranceName($invoice->getSupplementaryInsuranceId()); + return $data; } diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index 68653448..da3e7c93 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -18,6 +18,7 @@ use App\Insurance\Repository\TenantInsuranceRepository; use App\Insurance\Repository\TenantServiceCoverageRepository; use App\Insurance\Service\InsuranceCoverageDefaultService; use App\Insurance\Service\TenantInsuranceService; +use App\Insurance\Service\TenantServiceCategoryService; use App\Shared\Constant\ErrorCodes; use App\Secretary\Security\SecretaryAccessChecker; use App\Shared\Controller\BaseController; @@ -43,6 +44,7 @@ class InsuranceController extends BaseController private readonly TenantServiceCoverageRepository $serviceCoverageRepo, private readonly TenantInsuranceService $tenantInsuranceService, private readonly InsuranceCoverageDefaultService $coverageDefaults, + private readonly TenantServiceCategoryService $serviceCategories, private readonly ServiceItemRepository $serviceItemRepo, private readonly FileValidatorService $fileValidator, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, @@ -350,11 +352,15 @@ class InsuranceController extends BaseController }, $catalog); return [ - 'entity_type' => $entityType, - 'entity_id' => $entityId, - 'free_visit_price_rials' => $freeVisitPriceRials, - 'require_visit_price' => $requireVisitPrice, - 'insurances' => $insurances, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'free_visit_price_rials' => $freeVisitPriceRials, + 'require_visit_price' => $requireVisitPrice, + 'insurances' => $insurances, + // نوع خدماتِ بیمه‌ایِ این tenant — سراسری برای همهٔ بیمه‌ها. + 'service_categories' => $this->serviceCategories->settingsRows($entityType, $entityId), + // null یعنی چند نوع فعال است و کاربر باید سرِ پذیرش انتخاب کند. + 'default_service_category' => $this->serviceCategories->defaultCategory($entityType, $entityId)?->value, ]; } @@ -411,6 +417,10 @@ class InsuranceController extends BaseController $this->pricingRepo->getEntityManager()->flush(); + if (array_key_exists('service_categories', $data)) { + $this->serviceCategories->save($entityType, $entityId, (array) ($data['service_categories'] ?? [])); + } + return $this->success($this->pricingPayload($entityType, $entityId)); } diff --git a/src/Insurance/Entity/TenantServiceCategorySetting.php b/src/Insurance/Entity/TenantServiceCategorySetting.php new file mode 100644 index 00000000..7d4cbf9f --- /dev/null +++ b/src/Insurance/Entity/TenantServiceCategorySetting.php @@ -0,0 +1,73 @@ + true])] + private bool $enabled = true; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId, ServiceCategory $serviceCategory, bool $enabled = true) + { + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->serviceCategory = $serviceCategory; + $this->enabled = $enabled; + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; } + public function isEnabled(): bool { return $this->enabled; } + + public function setEnabled(bool $v): self + { + $this->enabled = $v; + $this->updatedAt = time(); + return $this; + } + + public function toArray(): array + { + return [ + 'key' => $this->serviceCategory->value, + 'label' => $this->serviceCategory->label(), + 'enabled' => $this->enabled, + ]; + } +} diff --git a/src/Insurance/Repository/TenantServiceCategorySettingRepository.php b/src/Insurance/Repository/TenantServiceCategorySettingRepository.php new file mode 100644 index 00000000..3ab0a584 --- /dev/null +++ b/src/Insurance/Repository/TenantServiceCategorySettingRepository.php @@ -0,0 +1,51 @@ + service_category => enabled, only stored rows */ + public function enabledMapFor(string $entityType, int $entityId): array + { + $rows = $this->findBy(['entityType' => $entityType, 'entityId' => $entityId]); + + $map = []; + foreach ($rows as $row) { + $map[$row->getServiceCategory()->value] = $row->isEnabled(); + } + + return $map; + } + + public function findOneFor(string $entityType, int $entityId, ServiceCategory $category): ?TenantServiceCategorySetting + { + return $this->findOneBy([ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'serviceCategory' => $category, + ]); + } + + public function save(TenantServiceCategorySetting $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function flush(): void + { + $this->getEntityManager()->flush(); + } +} diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php index 12399a33..804fd225 100644 --- a/src/Insurance/Service/TenantInsuranceService.php +++ b/src/Insurance/Service/TenantInsuranceService.php @@ -216,12 +216,16 @@ class TenantInsuranceService } /** - * قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator). - * ویزیت خدمتِ سرپایی است، پس درصد همان نوع خدمت resolve می‌شود. + * قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator). درصد بر پایهٔ نوع + * خدمتِ داده‌شده resolve می‌شود؛ پیش‌فرض سرپایی است چون ویزیت آیتم سرویس نیست. * اگر قرارداد فعالی نباشد، notCovered برمی‌گردد. */ - public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule - { + public function coverageRule( + string $entityType, + int $entityId, + ?int $insuranceId, + ServiceCategory $category = ServiceCategory::Outpatient, + ): CoverageRule { if ($insuranceId === null) { return CoverageRule::notCovered(); } @@ -231,7 +235,7 @@ class TenantInsuranceService return CoverageRule::notCovered(); } - return $this->buildRule($contract, ServiceCategory::Outpatient, null); + return $this->buildRule($contract, $category, null); } /** diff --git a/src/Insurance/Service/TenantServiceCategoryService.php b/src/Insurance/Service/TenantServiceCategoryService.php new file mode 100644 index 00000000..e7f2b199 --- /dev/null +++ b/src/Insurance/Service/TenantServiceCategoryService.php @@ -0,0 +1,110 @@ + enabled service_category values */ + public function enabledKeys(string $entityType, int $entityId): array + { + $stored = $this->repo->enabledMapFor($entityType, $entityId); + + return array_values(array_filter( + ServiceCategory::values(), + static fn(string $key) => $stored[$key] ?? true, + )); + } + + /** + * Rows shaped for the settings UI — always every category, so the panel renders a + * complete list without knowing which kinds exist. + * + * @return list + */ + public function settingsRows(string $entityType, int $entityId): array + { + $stored = $this->repo->enabledMapFor($entityType, $entityId); + + return array_map(static fn(ServiceCategory $c) => [ + 'key' => $c->value, + 'label' => $c->label(), + 'enabled' => $stored[$c->value] ?? true, + ], ServiceCategory::cases()); + } + + /** + * The kind to bill with when nobody picked one: the single enabled kind, or null + * when more than one is enabled (then the panel must ask). + */ + public function defaultCategory(string $entityType, int $entityId): ?ServiceCategory + { + $enabled = $this->enabledKeys($entityType, $entityId); + + return count($enabled) === 1 ? ServiceCategory::from($enabled[0]) : null; + } + + public function isEnabled(string $entityType, int $entityId, ServiceCategory $category): bool + { + return in_array($category->value, $this->enabledKeys($entityType, $entityId), true); + } + + /** + * @param list $rows + * @throws AppException on an unknown category, or when the change would disable every kind + */ + public function save(string $entityType, int $entityId, array $rows): void + { + $wanted = []; + foreach ($rows as $row) { + $category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null); + if ($category === null) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()), + 422, + 'service_categories', + ); + } + + $wanted[$category->value] = (bool) ($row['enabled'] ?? false); + } + + $resulting = array_filter( + ServiceCategory::values(), + fn(string $key) => $wanted[$key] ?? in_array($key, $this->enabledKeys($entityType, $entityId), true), + ); + + if ($resulting === []) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'حداقل یک نوع خدمت باید فعال باشد', + 422, + 'service_categories', + ); + } + + foreach ($wanted as $key => $enabled) { + $category = ServiceCategory::from($key); + $entity = $this->repo->findOneFor($entityType, $entityId, $category) + ?? new TenantServiceCategorySetting($entityType, $entityId, $category); + + $this->repo->save($entity->setEnabled($enabled), false); + } + + $this->repo->flush(); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index c8ec87a6..6a1c3712 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -1002,9 +1002,23 @@ class PatientController extends BaseController * مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending) * بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی. */ + /** @var array نام بیمه‌ها، یک‌بار در هر درخواست (لیست مراجعه‌ها N+1 نشود). */ + private array $insuranceNameCache = []; + + private function insuranceNameById(?int $id): ?string + { + if ($id === null) { + return null; + } + + return $this->insuranceNameCache[$id] ??= $this->insuranceRepo->find($id)?->getName(); + } + private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array { $data = $session->toArray(); + $data['insurance_base_name'] = $this->insuranceNameById($session->getInsuranceBaseId()); + $data['insurance_supplementary_name'] = $this->insuranceNameById($session->getInsuranceSupplementaryId()); $invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null; $data['invoice_uuid'] = $invoice?->getUuid(); @@ -1035,7 +1049,7 @@ class PatientController extends BaseController $this->autoCreateClaim($session, $entityType, $entityId); - return $this->success($session->toArray(), 201); + return $this->success($this->sessionWithBilling($session), 201); } /** @@ -1130,7 +1144,7 @@ class PatientController extends BaseController $this->sessionRepo->save($session); - return $this->success($session->toArray()); + return $this->success($this->sessionWithBilling($session)); } /** diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index 8d18a01b..e3851669 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -3,6 +3,7 @@ namespace App\Patient\Entity; use App\Appointment\Entity\Appointment; +use App\Insurance\Enum\ServiceCategory; use App\Inventory\Entity\InventoryPackage; use App\Patient\Repository\PatientSessionRepository; use Doctrine\Common\Collections\ArrayCollection; @@ -46,6 +47,13 @@ class PatientSession #[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)] private ?int $insuranceSupplementaryId = null; + /** + * نوع خدمتِ بیمه‌ایِ این مراجعه (سرپایی/بستری) — مبنای درصد پوششِ ویزیت و + * snapshotی که فاکتور از آن می‌سازد. + */ + #[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)] + private ?ServiceCategory $insuranceServiceCategory = null; + #[ORM\Column(name: 'visit_price_rials', type: 'integer')] private int $visitPriceRials = 0; @@ -149,6 +157,7 @@ class PatientSession public function getAppointment(): ?Appointment { return $this->appointment; } public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; } public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; } + public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; } public function getServices(): Collection { return $this->services; } public function addService(SessionService $service): self @@ -230,6 +239,7 @@ class PatientSession public function setInsuranceBaseId(?int $id): self { $this->insuranceBaseId = $id; $this->updatedAt = time(); return $this; } public function setInsuranceSupplementaryId(?int $id): self { $this->insuranceSupplementaryId = $id; $this->updatedAt = time(); return $this; } + public function setInsuranceServiceCategory(?ServiceCategory $c): self { $this->insuranceServiceCategory = $c; $this->updatedAt = time(); return $this; } public function setVisitPriceRials(int $v): self { $this->visitPriceRials = $v; $this->updatedAt = time(); return $this; } public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; } public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; } @@ -281,6 +291,8 @@ class PatientSession 'doctor_name' => $this->appointment?->getDoctor()->getName(), 'insurance_base_id' => $this->insuranceBaseId, 'insurance_supplementary_id' => $this->insuranceSupplementaryId, + 'insurance_service_category' => $this->insuranceServiceCategory?->value, + 'insurance_service_category_label' => $this->insuranceServiceCategory?->label(), 'visit_price_rials' => $this->visitPriceRials, 'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent, 'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent, diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index 04256f1a..ed70a3a9 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -3,11 +3,13 @@ namespace App\Patient\Service; use App\Appointment\Entity\Appointment; +use App\Appointment\Service\AppointmentInsuranceService; use App\Auth\Repository\UserRepository; use App\Billing\Service\BillingCalculator; use App\Billing\ValueObject\Money; use App\ClinicService\Repository\ServiceItemRepository; use App\Clinic\Repository\ClinicRepository; +use App\Insurance\Enum\ServiceCategory; use App\Insurance\Repository\EntityInsurancePricingRepository; use App\Insurance\Service\TenantInsuranceService; use App\Inventory\Repository\InventoryItemRepository; @@ -48,6 +50,7 @@ class PatientService private readonly DoctorAddressRepository $addressRepo, private readonly ClinicRepository $clinicRepo, private readonly TenantInsuranceService $tenantInsuranceService, + private readonly AppointmentInsuranceService $appointmentInsurance, private readonly BillingCalculator $billingCalculator, private readonly WalletService $walletService, private readonly EntityInsurancePricingRepository $pricingRepo, @@ -95,6 +98,7 @@ class PatientService * صفحه‌ی پرداخت و فاکتور نتوانند از هم واگرا شوند. * * @param array $serviceItems قیمت کل هر ردیف (با احتساب تعداد) + * @param ?ServiceCategory $visitCategory نوع خدمتِ ویزیت؛ null → سرپایی * @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int} */ public function calculateFinalPrice( @@ -104,7 +108,9 @@ class PatientService int $entityId = 0, ?int $baseInsuranceId = null, ?int $suppInsuranceId = null, + ?ServiceCategory $visitCategory = null, ): array { + $visitCategory ??= ServiceCategory::Outpatient; $baseShare = 0; $suppShare = 0; $patientShare = 0; @@ -112,8 +118,8 @@ class PatientService if ($visitPrice > 0) { $visit = $this->billingCalculator->calculateItem( new Money($visitPrice), - $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId), - $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId), + $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId, $visitCategory), + $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId, $visitCategory), ); $baseShare += $visit->baseInsuranceRials; $suppShare += $visit->supplementaryRials; @@ -148,9 +154,15 @@ class PatientService * درصد پوشش مؤثر ویزیت (سرپایی) از همان زنجیره‌ی resolve محاسبه — * snapshot نمایشی روی مراجعه، نه ورودی محاسبه. */ - private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float - { - return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent; + private function contractPercent( + string $entityType, + int $entityId, + ?int $insuranceId, + ?ServiceCategory $category = null, + ): float { + return $this->tenantInsuranceService + ->coverageRule($entityType, $entityId, $insuranceId, $category ?? ServiceCategory::Outpatient) + ->coveragePercent; } /** @@ -219,9 +231,35 @@ class PatientService } $session->setServicesTotalRials($servicesTotal); - // پذیرش خودکار بیمه‌ای ندارد: تمام مبلغ سهم بیمار است. - $gross = $servicesTotal + $visitPrice; - $session->applyShares($gross, 0, 0, $gross); + + // بیمهٔ انتخاب‌شده روی نوبت مبنای محاسبه است؛ نوبتِ بدون بیمه مثل قبل کاملاً + // سهم بیمار می‌ماند (coverageRule برای insuranceId=null، notCovered می‌دهد). + $category = $this->appointmentInsurance->effectiveCategory($appointment); + $session->setInsuranceServiceCategory($category); + $session->setInsuranceBaseId($appointment->getInsuranceBaseId()); + $session->setBaseInsuranceDiscountPercent( + $this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category) + ); + + $shares = $this->calculateFinalPrice( + $visitPrice, + array_map( + fn(SessionService $line) => ['item_id' => $line->getServiceItem()->getId(), 'price_rials' => $line->getLineTotalRials()], + $lines, + ), + $entityType, + $entityId, + $appointment->getInsuranceBaseId(), + null, + $category, + ); + + $session->applyShares( + $shares['gross_total_rials'], + $shares['base_insurance_rials'], + $shares['supplementary_insurance_rials'], + $shares['patient_share_rials'], + ); $this->sessionRepo->save($session); @@ -252,9 +290,11 @@ class PatientService $session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null); $session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null); + $session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'] ?? null)); $session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0)); - $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId())); - $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId())); + $visitCategory = $session->getInsuranceServiceCategory(); + $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory)); + $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory)); $session->setPaymentMethod($data['payment_method'] ?? 'pending'); $session->setNotes($data['notes'] ?? null); @@ -288,6 +328,7 @@ class PatientService $entityId, $session->getInsuranceBaseId(), $session->getInsuranceSupplementaryId(), + $visitCategory, ); $session->setServicesTotalRials($priceCalc['services_total_rials']); @@ -357,6 +398,7 @@ class PatientService if (array_key_exists('visit_price_rials', $data)) { $session->setVisitPriceRials((int) $data['visit_price_rials']); } if (array_key_exists('insurance_base_id', $data)) { $session->setInsuranceBaseId($data['insurance_base_id'] !== null ? (int) $data['insurance_base_id'] : null); } if (array_key_exists('insurance_supplementary_id', $data)) { $session->setInsuranceSupplementaryId($data['insurance_supplementary_id'] !== null ? (int) $data['insurance_supplementary_id'] : null); } + if (array_key_exists('insurance_service_category', $data)) { $session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'])); } if (array_key_exists('base_insurance_discount_percent', $data)) { $session->setBaseInsuranceDiscountPercent((float) $data['base_insurance_discount_percent']); } if (array_key_exists('supplementary_discount_percent', $data)) { $session->setSupplementaryDiscountPercent((float) $data['supplementary_discount_percent']); } if (array_key_exists('notes', $data)) { $session->setNotes($data['notes'] !== null ? (string) $data['notes'] : null); } @@ -400,8 +442,9 @@ class PatientService fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()], $session->getServices()->toArray(), ); - $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId())); - $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId())); + $visitCategory = $session->getInsuranceServiceCategory(); + $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory)); + $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory)); $priceCalc = $this->calculateFinalPrice( $session->getVisitPriceRials(), @@ -410,6 +453,7 @@ class PatientService $entityId, $session->getInsuranceBaseId(), $session->getInsuranceSupplementaryId(), + $visitCategory, ); $consumablesTotal = $session->getConsumablesTotalRials(); $session->setServicesTotalRials($priceCalc['services_total_rials']); diff --git a/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php b/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php new file mode 100644 index 00000000..40904119 --- /dev/null +++ b/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php @@ -0,0 +1,165 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر تست سهم بیمه'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeAppointment(Doctor $doctor): Appointment + { + $start = strtotime('+30 days') + random_int(0, 500_000) * 7; + $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); + $appointment->setVisitPriceRials(self::VISIT_RIALS); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + /** قرارداد پایه با درصد مرکزی: سرپایی ۷۰٪، بستری ۳۰٪. */ + private function makeInsuranceWithDefaults(Doctor $doctor): Insurance + { + $insurance = new Insurance('بیمه سهم ' . random_int(1000, 9999), InsuranceType::Basic); + $this->em->persist($insurance); + $this->em->flush(); + + $contract = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), $insurance->getId()); + $contract->setKind('basic'); + $this->em->persist($contract); + $this->em->flush(); + + static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [ + ['key' => 'outpatient', 'coverage_percent' => 70], + ['key' => 'inpatient', 'coverage_percent' => 30], + ]); + + return $insurance; + } + + private function reloadSession(string $uuid): PatientSession + { + $this->em->clear(); + + return $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $uuid]); + } + + public function testInpatientReferenceScenario(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeInsuranceWithDefaults($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(200, $this->responseCode()); + $session = $body['data']['session']; + self::assertSame(1_785_600, $session['base_insurance_rials']); + self::assertSame(4_166_400, $session['patient_share_rials']); + self::assertSame(4_166_400, $session['final_price_rials']); + } + + public function testOutpatientUsesItsOwnPercent(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeInsuranceWithDefaults($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), [ + 'insurance_service_category' => 'outpatient', + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(4_166_400, $body['data']['session']['base_insurance_rials']); // 70٪ + self::assertSame(1_785_600, $body['data']['session']['patient_share_rials']); + } + + /** نوبتِ بدون بیمه باید مثل قبل کاملاً سهم بیمار بماند. */ + public function testAppointmentWithoutInsuranceStaysFullyOnThePatient(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), []); + + self::assertSame(200, $this->responseCode()); + self::assertSame(0, $body['data']['session']['base_insurance_rials']); + self::assertSame(self::VISIT_RIALS, $body['data']['session']['patient_share_rials']); + } + + /** بدون انتخاب کاربر، تنها نوعِ فعالِ tenant مبنا می‌شود. */ + public function testSingleEnabledCategoryIsUsedWithoutAnExplicitChoice(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeInsuranceWithDefaults($doctor); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $doctor->getUser(), [ + 'service_categories' => [['key' => 'outpatient', 'enabled' => false]], + ]); + self::assertSame(200, $this->responseCode()); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), [ + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(200, $this->responseCode()); + // فقط «بستری» فعال است → ۳۰٪ + self::assertSame(1_785_600, $body['data']['session']['base_insurance_rials']); + + $session = $this->reloadSession($body['data']['session']['uuid']); + self::assertSame('inpatient', $session->getInsuranceServiceCategory()->value); + self::assertSame($insurance->getId(), $session->getInsuranceBaseId()); + } + + public function testInvoiceSnapshotsTheServiceCategory(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeInsuranceWithDefaults($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + 'insurance_base_id' => $insurance->getId(), + ]); + $session = $this->reloadSession($body['data']['session']['uuid']); + + $invoice = static::getContainer()->get(InvoiceService::class) + ->createFromSession($session, 'doctor', $doctor->getId()); + + self::assertSame('inpatient', $invoice->toArray()['service_category']); + self::assertSame('خدمات بستری', $invoice->toArray()['service_category_label']); + self::assertSame(1_785_600, $invoice->toArray()['base_insurance_rials']); + self::assertSame(4_166_400, $invoice->toArray()['patient_rials']); + } +} diff --git a/tests/Appointment/AppointmentInsuranceSelectionTest.php b/tests/Appointment/AppointmentInsuranceSelectionTest.php new file mode 100644 index 00000000..24ef240a --- /dev/null +++ b/tests/Appointment/AppointmentInsuranceSelectionTest.php @@ -0,0 +1,168 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر تست بیمهٔ نوبت'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeAppointment(Doctor $doctor): Appointment + { + // اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود. + $start = strtotime('+30 days') + random_int(0, 500_000) * 7; + $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); + $appointment->setVisitPriceRials(5_952_000); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + private function makeContract(Doctor $doctor, InsuranceType $type = InsuranceType::Basic): Insurance + { + $insurance = new Insurance('بیمه نوبت ' . random_int(1000, 9999), $type); + $this->em->persist($insurance); + $this->em->flush(); + + $contract = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), $insurance->getId()); + $contract->setCoveragePercent(30)->setKind($type->value); + $this->em->persist($contract); + $this->em->flush(); + + return $insurance; + } + + public function testPatchStoresCategoryAndInsurance(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeContract($doctor); + + $body = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + 'insurance_base_id' => $insurance->getId(), + 'version' => $appointment->getVersion(), + ]); + + // پاسخ PATCH تودرتوست: success(['data' => …]) → data.data + self::assertSame(200, $this->responseCode()); + $row = $body['data']['data']; + self::assertSame('inpatient', $row['insurance_service_category']); + self::assertSame('خدمات بستری', $row['insurance_service_category_label']); + self::assertSame($insurance->getId(), $row['insurance_base_id']); + } + + public function testPatchClearsTheSelectionWithNull(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeContract($doctor); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + 'insurance_base_id' => $insurance->getId(), + ]); + + $body = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_service_category' => null, + 'insurance_base_id' => null, + ]); + + self::assertSame(200, $this->responseCode()); + self::assertNull($body['data']['data']['insurance_service_category']); + self::assertNull($body['data']['data']['insurance_base_id']); + } + + public function testUnknownCategoryIsRejected(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_service_category' => 'dental', + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testDisabledCategoryIsRejected(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + static::getContainer()->get(TenantServiceCategoryService::class) + ->save('doctor', $doctor->getId(), [['key' => 'inpatient', 'enabled' => false]]); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testInsuranceWithoutAnActiveContractIsRejected(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + + $insurance = new Insurance('بیمه بی‌قرارداد ' . random_int(1000, 9999), InsuranceType::Basic); + $this->em->persist($insurance); + $this->em->flush(); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testSupplementaryInsuranceIsRejectedOnAnAppointment(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeContract($doctor, InsuranceType::Supplementary); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $doctor->getUser(), [ + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testConfirmAcceptsTheSelectionAndPersistsItOnTheAppointment(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor); + $insurance = $this->makeContract($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment/' . $appointment->getUuid() . '/confirm', $doctor->getUser(), [ + 'insurance_service_category' => 'inpatient', + 'insurance_base_id' => $insurance->getId(), + 'payments' => [], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame('inpatient', $body['data']['appointment']['insurance_service_category']); + self::assertSame($insurance->getId(), $body['data']['appointment']['insurance_base_id']); + } +} diff --git a/tests/Insurance/InsuranceServiceCategorySettingsApiTest.php b/tests/Insurance/InsuranceServiceCategorySettingsApiTest.php new file mode 100644 index 00000000..84d092c6 --- /dev/null +++ b/tests/Insurance/InsuranceServiceCategorySettingsApiTest.php @@ -0,0 +1,92 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست نوع خدمات بیمه'); + $this->em->persist($doctor); + $this->em->flush(); + + return $owner; + } + + public function testPricingPayloadCarriesEveryServiceCategory(): void + { + $owner = $this->makeDoctor(); + + $body = $this->authJson('GET', '/api/v1/insurance-pricing', $owner); + + $this->assertSame(200, $this->responseCode()); + $rows = $body['data']['service_categories']; + $this->assertSame(['outpatient', 'inpatient'], array_column($rows, 'key')); + $this->assertSame([true, true], array_column($rows, 'enabled')); + // هر دو فعال → پنل باید بپرسد. + $this->assertNull($body['data']['default_service_category']); + } + + public function testDisablingOneKindMakesTheOtherTheDefault(): void + { + $owner = $this->makeDoctor(); + + $body = $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'service_categories' => [['key' => 'inpatient', 'enabled' => false]], + ]); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame('outpatient', $body['data']['default_service_category']); + $enabled = array_column($body['data']['service_categories'], 'enabled', 'key'); + $this->assertFalse($enabled['inpatient']); + $this->assertTrue($enabled['outpatient']); + } + + public function testDisablingEveryKindIsRejected(): void + { + $owner = $this->makeDoctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'service_categories' => [ + ['key' => 'outpatient', 'enabled' => false], + ['key' => 'inpatient', 'enabled' => false], + ], + ]); + + $this->assertSame(422, $this->responseCode()); + } + + public function testUnknownKindIsRejected(): void + { + $owner = $this->makeDoctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'service_categories' => [['key' => 'dental', 'enabled' => true]], + ]); + + $this->assertSame(422, $this->responseCode()); + } + + /** بدون کلید `service_categories`، تنظیمات دست‌نخورده می‌ماند. */ + public function testOmittingTheKeyKeepsTheStoredSettings(): void + { + $owner = $this->makeDoctor(); + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'service_categories' => [['key' => 'inpatient', 'enabled' => false]], + ]); + + $body = $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['free_visit_price_rials' => 5_000_000]); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame('outpatient', $body['data']['default_service_category']); + $this->assertSame(5_000_000, $body['data']['free_visit_price_rials']); + } +} diff --git a/tests/Insurance/TenantServiceCategoryServiceTest.php b/tests/Insurance/TenantServiceCategoryServiceTest.php new file mode 100644 index 00000000..4373853b --- /dev/null +++ b/tests/Insurance/TenantServiceCategoryServiceTest.php @@ -0,0 +1,94 @@ +service = static::getContainer()->get(TenantServiceCategoryService::class); + // db_test is never reset — a fresh id per test keeps rows from leaking across cases. + $this->entityId = random_int(100_000, 999_999); + } + + public function testEveryKindIsEnabledWithoutStoredRows(): void + { + $this->assertSame(ServiceCategory::values(), $this->service->enabledKeys('doctor', $this->entityId)); + $this->assertNull($this->service->defaultCategory('doctor', $this->entityId)); + } + + public function testSettingsRowsAlwaysCoverEveryKind(): void + { + $rows = $this->service->settingsRows('doctor', $this->entityId); + + $this->assertSame(ServiceCategory::values(), array_column($rows, 'key')); + $this->assertSame([true, true], array_column($rows, 'enabled')); + } + + public function testSingleEnabledKindBecomesTheDefault(): void + { + $this->service->save('doctor', $this->entityId, [ + ['key' => 'inpatient', 'enabled' => false], + ]); + + $this->assertSame(['outpatient'], $this->service->enabledKeys('doctor', $this->entityId)); + $this->assertSame(ServiceCategory::Outpatient, $this->service->defaultCategory('doctor', $this->entityId)); + $this->assertFalse($this->service->isEnabled('doctor', $this->entityId, ServiceCategory::Inpatient)); + } + + public function testBothEnabledLeavesTheChoiceToTheUser(): void + { + $this->service->save('doctor', $this->entityId, [ + ['key' => 'outpatient', 'enabled' => true], + ['key' => 'inpatient', 'enabled' => true], + ]); + + $this->assertNull($this->service->defaultCategory('doctor', $this->entityId)); + } + + public function testDisablingEveryKindIsRejected(): void + { + $this->expectException(AppException::class); + + $this->service->save('doctor', $this->entityId, [ + ['key' => 'outpatient', 'enabled' => false], + ['key' => 'inpatient', 'enabled' => false], + ]); + } + + public function testDisablingTheLastRemainingKindIsRejected(): void + { + $this->service->save('doctor', $this->entityId, [['key' => 'inpatient', 'enabled' => false]]); + + $this->expectException(AppException::class); + $this->service->save('doctor', $this->entityId, [['key' => 'outpatient', 'enabled' => false]]); + } + + public function testUnknownKindIsRejected(): void + { + $this->expectException(AppException::class); + + $this->service->save('doctor', $this->entityId, [['key' => 'dental', 'enabled' => true]]); + } + + public function testSettingsAreIsolatedPerTenant(): void + { + $this->service->save('doctor', $this->entityId, [['key' => 'inpatient', 'enabled' => false]]); + + $this->assertSame(['outpatient'], $this->service->enabledKeys('doctor', $this->entityId)); + $this->assertSame(ServiceCategory::values(), $this->service->enabledKeys('clinic', $this->entityId)); + } +}