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:
@@ -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).
|
||||
Reference in New Issue
Block a user