feat(insurance): bill an appointment with a chosen service kind and insurance

An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
@@ -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<string> نوع‌های فعال؛ نبودِ ردیف = همهٔ نوع‌ها فعال (سازگاری عقب‌رو) */
public function enabledKeys(string $entityType, int $entityId): array;
/** @return list<array{key: string, label: string, enabled: bool}> برای 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<array{key?: string, enabled?: mixed}> $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` (هرگز `<select>` بومی) · مودال/کارت جدید با تم و کامپوننت‌های موجود، بدون طراحی تازه · `tenant-insurances` در فرانت با `(data as any)?.data?.data` خوانده می‌شود.
- **`ServiceItem.insurance_covered`** همچنان gate نهاییِ هر خدمت است؛ نوع خدمت آن را دور نمی‌زند.
- بعد از اتمام: `graphify update .` (پس از commit).
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, 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 InsuranceServiceCategoriesCard from './InsuranceServiceCategoriesCard';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
function mockCategories(enabled: Record<string, boolean>) {
get.mockResolvedValue({
success: true,
data: {
service_categories: [
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.outpatient },
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabled.inpatient },
],
},
});
}
beforeEach(() => {
get.mockReset();
put.mockReset();
put.mockResolvedValue({ success: true, data: {} });
});
describe('InsuranceServiceCategoriesCard', () => {
it('سوییچ هر نوع خدمت را با وضعیت سرور نشان می‌دهد', async () => {
mockCategories({ outpatient: true, inpatient: false });
renderWithProviders(<InsuranceServiceCategoriesCard />);
expect(await screen.findByText('نوع خدمات بیمه')).toBeInTheDocument();
expect(screen.getByLabelText('خدمات سرپایی')).toBeChecked();
expect(screen.getByLabelText('خدمات بستری')).not.toBeChecked();
});
it('تغییر سوییچ فقط همان نوع را PUT می‌کند', async () => {
mockCategories({ outpatient: true, inpatient: true });
renderWithProviders(<InsuranceServiceCategoriesCard />);
await screen.findByText('نوع خدمات بیمه');
fireEvent.click(screen.getByLabelText('خدمات بستری'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
service_categories: [
{ key: 'outpatient', enabled: true },
{ key: 'inpatient', enabled: false },
],
}));
});
it('غیرفعال‌کردن آخرین نوع فعال ارسال نمی‌شود', async () => {
mockCategories({ outpatient: true, inpatient: false });
renderWithProviders(<InsuranceServiceCategoriesCard />);
await screen.findByText('نوع خدمات بیمه');
fireEvent.click(screen.getByLabelText('خدمات سرپایی'));
expect(put).not.toHaveBeenCalled();
expect(screen.getByLabelText('خدمات سرپایی')).toBeChecked();
});
});
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { usePermissions } from '../hooks/usePermissions';
interface ServiceCategoryRow {
key: string;
label: string;
enabled: boolean;
}
/**
* «نوع خدمات بیمه» — تنظیمی سراسری برای همهٔ بیمه‌های این پزشک/کلینیک: بیمه‌ها کدام
* نوع خدمات را پوشش می‌دهند. اگر فقط یک نوع فعال بماند، همان به‌صورت خودکار مبنای
* محاسبه است و سرِ پذیرش چیزی پرسیده نمی‌شود.
*/
export default function InsuranceServiceCategoriesCard() {
const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('insurances', 'update');
const [rows, setRows] = useState<ServiceCategoryRow[]>([]);
const { data } = useQuery<ApiResponse<{ service_categories?: ServiceCategoryRow[] }>>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const serverRows = data?.data?.service_categories ?? [];
useEffect(() => {
if (serverRows.length > 0) setRows(serverRows);
}, [data]);
const save = useMutation({
mutationFn: (next: ServiceCategoryRow[]) => api.put('/api/v1/insurance-pricing', {
service_categories: next.map((r) => ({ key: r.key, enabled: r.enabled })),
}),
onSuccess: () => {
toast.success('نوع خدمات بیمه ذخیره شد');
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
},
onError: (e: Error) => {
toast.error(e.message);
setRows(serverRows);
},
});
const toggle = (key: string) => {
const next = rows.map((r) => (r.key === key ? { ...r, enabled: !r.enabled } : r));
if (next.every((r) => !r.enabled)) {
toast.error('حداقل یک نوع خدمت باید فعال باشد');
return;
}
setRows(next);
save.mutate(next);
};
if (rows.length === 0) return null;
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 6px' }}>نوع خدمات بیمه</h2>
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.9, color: 'var(--text-2)' }}>
بیمههای شما کدام نوع خدمات را پوشش میدهند؟ این تنظیم برای همهٔ بیمهها یکسان است.
اگر فقط یک نوع فعال باشد، همان بهصورت پیشفرض برای محاسبهٔ بیمه استفاده میشود؛
با فعال بودن هر دو، هنگام قطعیکردن نوبت نوع خدمت پرسیده میشود.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
{rows.map((row) => (
<label
key={row.key}
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
>
<span className="switch">
<input
type="checkbox"
aria-label={row.label}
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>{row.label}</span>
</label>
))}
</div>
</div>
);
}
@@ -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(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات بیمه')).toBeInTheDocument());
expect(screen.getByText('نوع خدمت بیمه')).toBeInTheDocument();
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
});
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
mockInvoice(baseInvoice);
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
expect(screen.queryByText('اطلاعات بیمه')).not.toBeInTheDocument();
});
});
@@ -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
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}</span>,
]]}
/>
{(inv.base_insurance_name || inv.service_category_label) && (
<SectionTable
title="اطلاعات بیمه"
cols={['نوع خدمت بیمه', 'بیمه', 'سهم بیمه', 'سهم بیمار']}
rows={[[
inv.service_category_label ?? '—',
inv.base_insurance_name ?? '—',
formatRial(summary.baseInsurance + summary.suppInsurance),
formatRial(summary.patientShare),
]]}
/>
)}
<SectionTable
title="اطلاعات سرویس"
cols={['سرویس', 'تعداد', 'مبلغ']}
@@ -21,6 +21,10 @@ export interface SessionCardData {
session_at?: number | null;
insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
/** نوع خدمتِ بیمه‌ایِ این مراجعه (سرپایی/بستری) و نام بیمهٔ پایه. */
insurance_service_category?: string | null;
insurance_service_category_label?: string | null;
insurance_base_name?: string | null;
base_insurance_discount_percent?: number;
supplementary_discount_percent?: number;
doctor_name?: string | null;
@@ -154,6 +158,13 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, o
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{session.insurance_service_category_label && (
<Row label="نوع خدمت بیمه" value={session.insurance_service_category_label} />
)}
{session.insurance_base_name && <Row label="بیمه" value={session.insurance_base_name} />}
{(session.base_insurance_rials ?? 0) > 0 && (
<Row label="سهم بیمه" value={formatRial(session.base_insurance_rials ?? 0)} valueStyle={{ color: '#16A34A', fontWeight: 500 }} />
)}
<Row label="هزینه" value={formatRial(session.final_price_rials ?? 0)} valueStyle={{ fontWeight: 500 }} />
{!paid && <Row label="مانده بدهی" value={formatRial(debt)} valueStyle={{ color: '#EF4444', fontWeight: 500 }} />}
</div>
@@ -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', () => {
@@ -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 (
<div style={{
@@ -317,7 +320,10 @@ function ContractDetails({ contract: c, categories }: { contract: Contract; cate
padding: 14,
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
}}>
{categories.filter((cat) => cat.key in percents).map((cat) => (
{perCategory.length === 0 && (
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
)}
{perCategory.map((cat) => (
<DetailCell
key={cat.key}
label={`درصد پوشش — ${cat.label}`}
@@ -67,7 +67,7 @@ describe('ConfirmAppointmentModal', () => {
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(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={referenceAppointment} onClose={() => {}} />,
);
}
/**
* 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(
<ConfirmAppointmentModal
open
appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
onClose={() => {}}
/>,
);
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
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('۴۱۶٬۶۴۰'));
});
});
@@ -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<string>('');
const [insuranceId, setInsuranceId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ 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<ApiResponse<unknown>>(`/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({
</div>
)}
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{insurance.needsCategoryChoice && (
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>نوع خدمت</label>
<SearchableSelect
value={serviceCategory}
onChange={(v) => setServiceCategory(v == null ? '' : String(v))}
options={insurance.categoryOptions}
placeholder="انتخاب نوع خدمت"
height={40}
/>
</div>
)}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label>
<SearchableSelect
value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
options={insurance.insuranceOptions}
placeholder="بدون بیمه"
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
isClearable
height={40}
/>
</div>
</div>
{/* هزینه‌ها */}
<div
style={{
@@ -271,14 +335,24 @@ export default function ConfirmAppointmentModal({
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>جمع کل</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
</div>
{insuranceId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong>
</div>
)}
<div
style={{
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>جمع کل</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div>
</div>
@@ -397,7 +471,7 @@ export default function ConfirmAppointmentModal({
{overpaid && (
<p className="field-err" style={{ marginBottom: 14 }}>
مجموع پرداختها از جمع کل بیشتر است.
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
</p>
)}
@@ -410,7 +484,7 @@ export default function ConfirmAppointmentModal({
>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, total))}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقیمانده</span>
@@ -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> = {}): 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<typeof vi.fn>).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(<TurnsTimeline slots={[insured]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(await screen.findByText('خدمات بستری · بیمه ایران')).toBeInTheDocument();
});
it('نوبتِ بدون بیمه چیپی نشان نمی‌دهد', () => {
renderWithProviders(<TurnsTimeline slots={[occupiedSlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.queryByText(/بیمه ایران/)).toBeNull();
});
});
@@ -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 (
<div
onClick={() => onView(a)}
@@ -133,6 +141,18 @@ function OccupiedCard({
سرویس: {a.service_item?.name || '—'}
</span>
</div>
{insuranceChip !== '' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<ShieldCheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 'var(--r-pill)',
background: 'var(--primary-soft)', color: 'var(--primary)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{insuranceChip}
</span>
</div>
)}
</div>
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
@@ -145,6 +165,7 @@ function OccupiedCard({
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={a.uuid}
appointment={a}
onClose={() => setConfirmOpen(false)}
queryKey={queryKey}
/>
@@ -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(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
mockInsuranceEndpoints(['outpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است)
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200'));
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
it('انتخاب نوع خدمت، درصد پوشش همان نوع را روی فرم می‌گذارد و ارسال می‌کند', async () => {
mockInsuranceEndpoints(['outpatient', 'inpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
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(
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
});
it('بدون هیچ قراردادی، بلوک بیمه نمایش داده نمی‌شود', async () => {
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
renderWithProviders(
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
);
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
});
it('مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود', async () => {
mockInsuranceEndpoints(['outpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
// بیمهٔ پروفایل (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(
<CreateStep recordUuid="r-1" profile={null} editSession={editSession} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30');
// قابل تغییر: انتخاب نوع سرپایی درصد را به ۷۰ می‌برد
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70'));
});
});
+79 -35
View File
@@ -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<string, number> }
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 && (
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>بیمه</span>
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود؛ وگرنه همان نوعِ فعال. */}
{needsCategoryChoice && (
<div style={{ marginBottom: 12 }}>
<span style={fieldLabel}>نوع خدمت</span>
<SearchableSelect
inputId="service-category-select"
options={enabledCategories.map(c => ({ value: c.key, label: c.label }))}
value={serviceCategory || null}
onChange={v => applyServiceCategory(v ? String(v) : '')}
placeholder="انتخاب نوع خدمت"
isClearable
/>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<span style={fieldLabel}>بیمه پایه</span>
@@ -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<ApiResponse<PricingPayload>>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled,
});
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
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,
};
}
@@ -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<typeof vi.fn>;
function Probe() {
const { categories } = useServiceCategories();
return <div data-testid="labels">{categories.map((c) => c.label).join('|')}</div>;
}
beforeEach(() => get.mockReset());
describe('useServiceCategories', () => {
/** کنترلر با success(['data' => …]) پاسخ می‌دهد، پس آرایه یک لایه تودرتوست. */
it('آرایه را از پاسخ تودرتوی کنترلر بیرون می‌کشد', async () => {
get.mockResolvedValue({ success: true, data: { data: [
{ key: 'outpatient', label: 'خدمات سرپایی' },
{ key: 'inpatient', label: 'خدمات بستری' },
] } });
renderWithProviders(<Probe />);
await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent('خدمات سرپایی|خدمات بستری'));
});
it('پاسخ تخت (بدون لایهٔ اضافه) هم پذیرفته می‌شود', async () => {
get.mockResolvedValue({ success: true, data: [{ key: 'outpatient', label: 'خدمات سرپایی' }] });
renderWithProviders(<Probe />);
await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent('خدمات سرپایی'));
});
it('پاسخ نامعتبر → لیست خالی، بدون خطا', async () => {
get.mockResolvedValue({ success: true, data: { insurances: [] } });
renderWithProviders(<Probe />);
await waitFor(() => expect(screen.getByTestId('labels')).toHaveTextContent(''));
});
});
+2 -1
View File
@@ -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 };
}
+97
View File
@@ -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<string, number>;
}
/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */
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<ShareBreakdown>((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 });
}
@@ -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,
})));
});
});
+72 -1
View File
@@ -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<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
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() {
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیمه:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
{insurance.needsCategoryChoice && (
<div>
<label style={label}>نوع خدمت</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={insurance.categoryOptions}
value={serviceCategory || null}
onChange={v => setServiceCategory(v ? String(v) : '')}
placeholder="انتخاب نوع خدمت"
isClearable
height={38}
/>
</div>
</div>
)}
<div>
<label style={label}>بیمه</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={insurance.insuranceOptions}
value={insuranceId || null}
onChange={v => setInsuranceId(v ? String(v) : '')}
placeholder="بدون بیمه"
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
isClearable
height={38}
/>
</div>
</div>
{insuranceId !== '' && (
<div>
<label style={label}>سهم بیمه / سهم بیمار</label>
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, minHeight: 38, fontSize: 13 }}>
<span style={{ color: 'var(--success)', fontWeight: 700 }}>{formatRial(shares.insurance)}</span>
<span style={{ color: 'var(--text-3)' }}>/</span>
<span style={{ color: 'var(--primary)', fontWeight: 700 }}>{formatRial(shares.patient)}</span>
</div>
</div>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
<div>
+2 -1
View File
@@ -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 () => {
@@ -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="قراردادهای بیمه پایه و تکمیلی"
/>
<InsuranceServiceCategoriesCard />
<TenantInsuranceContracts />
</div>
</FeatureGate>
+6
View File
@@ -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 {
+35 -3
View File
@@ -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.
+4 -1
View File
@@ -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,
+20
View File
@@ -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`) — نوع خدمت نامعتبر، یا غیرفعال‌کردن همهٔ نوع‌ها.
---
+6
View File
@@ -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)
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Tenant-wide switch for the service kinds (outpatient/inpatient) an insurance covers.
* No backfill: a missing row reads as enabled, so existing tenants keep both kinds.
*/
final class Version20260725131031 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tenant_service_category_settings (per-tenant enabled insurance service kinds)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Insurance selection carried by an appointment: the service kind it is billed as
* (outpatient/inpatient) and the chosen basic insurance. Both nullable an
* appointment without insurance keeps its previous behaviour.
*/
final class Version20260725131554 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add appointments.insurance_service_category and appointments.insurance_base_id';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The insurance service kind a visit was billed as, snapshotted on the encounter and
* on the invoice built from it.
*/
final class Version20260725132739 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add patient_sessions.insurance_service_category and invoices.service_category';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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);
+23
View File
@@ -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,
@@ -0,0 +1,139 @@
<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Service\TenantServiceCategoryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* The insurance an appointment is billed with: which service kind (outpatient/inpatient)
* and which basic insurance. Single place so the PATCH endpoint, the confirm endpoint and
* the session/invoice pipeline agree on the same rules.
*/
class AppointmentInsuranceService
{
public function __construct(
private readonly TenantServiceCategoryService $serviceCategories,
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly InsuranceRepository $insuranceRepo,
) {}
/**
* موجودیتِ صاحب نوبت کلینیک اگر نوبت در کلینیک باشد، وگرنه خودِ پزشک.
* همان تفکیکی که PatientService برای ساخت پرونده به‌کار می‌برد.
*
* @return array{0: string, 1: int}
*/
public function tenantOf(Appointment $appointment): array
{
$clinic = $appointment->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<string, mixed> $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;
}
}
+9
View File
@@ -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,
+25 -3
View File
@@ -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<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
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;
}
@@ -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,
@@ -355,6 +357,10 @@ class InsuranceController extends BaseController
'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));
}
@@ -0,0 +1,73 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantServiceCategorySettingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Which kinds of service a doctor/clinic covers with insurance at all. This is a
* tenant-wide switch shared by every insurance of that tenant not a per-insurance
* setting. A missing row reads as enabled, so tenants created before this setting
* keep both kinds available.
*/
#[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
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[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;
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,
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantServiceCategorySetting;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantServiceCategorySettingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantServiceCategorySetting::class);
}
/** @return array<string, bool> 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();
}
}
@@ -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);
}
/**
@@ -0,0 +1,110 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\TenantServiceCategorySetting;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantServiceCategorySettingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* The service kinds a tenant covers with insurance. One switch per kind, shared by
* all of that tenant's insurances. A kind with no stored row counts as enabled.
*/
class TenantServiceCategoryService
{
public function __construct(
private readonly TenantServiceCategorySettingRepository $repo,
) {}
/** @return list<string> 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<array{key: string, label: string, enabled: bool}>
*/
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<array{key?: string, enabled?: mixed}> $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();
}
}
+16 -2
View File
@@ -1002,9 +1002,23 @@ class PatientController extends BaseController
* مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
* بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی.
*/
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست (لیست مراجعه‌ها 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));
}
/**
+12
View File
@@ -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,
+56 -12
View File
@@ -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<array{item_id: int, price_rials: int}> $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']);
@@ -0,0 +1,165 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Billing\Service\InvoiceService;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Patient\Entity\PatientSession;
use App\Tests\ApiTestCase;
/**
* Confirming an appointment used to hand the whole amount to the patient. With an
* insurance picked on the appointment the visit is now split through BillingCalculator,
* using the coverage percent of that appointment's service kind.
*/
class AppointmentConfirmInsuranceSharesTest extends ApiTestCase
{
private const VISIT_RIALS = 5_952_000;
private function makeDoctor(): Doctor
{
$user = $this->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']);
}
}
@@ -0,0 +1,168 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Service\TenantServiceCategoryService;
use App\Tests\ApiTestCase;
/**
* An appointment carries the insurance it is billed with: the service kind and the
* basic insurance. Both are validated against that tenant's own settings/contracts.
*/
class AppointmentInsuranceSelectionTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$user = $this->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']);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Tests\Insurance;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* The tenant-wide insurance service kinds travel on the existing insurance-pricing
* endpoint it already carries the tenant's global insurance settings.
*/
class InsuranceServiceCategorySettingsApiTest extends ApiTestCase
{
private function makeDoctor(): \App\Auth\Entity\User
{
$owner = $this->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']);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Tests\Insurance;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Service\TenantServiceCategoryService;
use App\Shared\Exception\AppException;
use App\Tests\ApiTestCase;
/**
* The tenant-wide switch for insurance service kinds. A tenant with no stored rows
* keeps every kind enabled, and the last enabled kind can never be turned off.
*/
class TenantServiceCategoryServiceTest extends ApiTestCase
{
private TenantServiceCategoryService $service;
private int $entityId;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}