# انتخاب نوع خدمت بیمه (سرپایی/بستری) در نوبت و انتشار آن تا فاکتور

## پروژه

`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).
