```
### ۴) قاعدهٔ پوشش، بیخبر از نوع خدمت — `src/Insurance/Service/TenantInsuranceService.php:111-138`
```php
return new CoverageRule(
coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(),
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
```
`ServiceItem` هیچ فیلدی برای «سرپایی/بستری» ندارد (فیلدها: `name`, `price_rials`, `active`, `insurance_covered`, `insurance_price_rials`, `duration_minutes`, `bookable`, `inventory_package_id`).
## وظایف
> ترتیب پیشنهادی: ۱ → ۹ (بکاند اول، سپس فرانت، سپس مستندات/تست). هر گام مستقل قابل تست باشد.
### ۱. Enum نوع خدمت (توسعهپذیر)
فایل جدید `src/Insurance/Enum/ServiceCategory.php`:
```php
namespace App\Insurance\Enum;
enum ServiceCategory: string
{
case Outpatient = 'outpatient'; // سرپایی
case Inpatient = 'inpatient'; // بستری
public function label(): string
{
return match ($this) {
self::Outpatient => 'خدمات سرپایی',
self::Inpatient => 'خدمات بستری',
};
}
/** @return list */
public static function values(): array
{
return array_map(static fn(self $c) => $c->value, self::cases());
}
}
```
افزودن نوع جدید در آینده = فقط یک `case` تازه؛ هیچ جای دیگری نباید لیست ثابت hardcode شود (نه در Entity، نه در Controller، نه در فرانت — فرانت لیست را از API میگیرد).
### ۲. جدول درصدهای پیشفرض ادمین
Entity جدید `src/Insurance/Entity/InsuranceCoverageDefault.php` + `src/Insurance/Repository/InsuranceCoverageDefaultRepository.php`:
```php
#[ORM\Entity(repositoryClass: InsuranceCoverageDefaultRepository::class)]
#[ORM\Table(name: 'insurance_coverage_defaults')]
#[ORM\UniqueConstraint(name: 'uniq_insurance_service_category', columns: ['insurance_id', 'service_category'])]
class InsuranceCoverageDefault
{
#[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'insurance_id', type: 'integer')]
private int $insuranceId;
#[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
private ServiceCategory $serviceCategory;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
// getters/setters + toArray() طبق الگوی سایر Entityهای Insurance
}
```
متد ریپازیتوری لازم:
```php
/** @return array service_category => percent */
public function percentMapFor(int $insuranceId): array;
/** @return array> insurance_id => (category => percent) — برای پرکردن لیستها بدون N+1 */
public function percentMapForMany(array $insuranceIds): array;
```
سپس:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
در همان migration، **backfill**: برای هر بیمهٔ `basic` یک ردیف `outpatient` و یک `inpatient` با مقدار ۰ ساخته شود تا پنل ادمین همیشه ردیف کامل نشان دهد (خالیبودن = «تعریفنشده» نه «صفرِ عمدی»).
### ۳. اندپوینتهای ادمین برای درصدهای پیشفرض
در `src/Insurance/Controller/InsuranceController.php` (کنار `/api/v1/admin/insurance/...`، همان `#[IsGranted('ROLE_ADMIN')]` که بقیهٔ اکشنهای ادمین دارند):
```php
#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function getCoverageDefaults(int $id): JsonResponse
{
// { categories: [ { key, label, coverage_percent } ] } — همیشه همهٔ caseهای ServiceCategory
}
#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['PUT'])]
#[IsGranted('ROLE_ADMIN')]
public function saveCoverageDefaults(int $id, Request $request): JsonResponse
{
// body: { categories: [ { key: 'outpatient', coverage_percent: 30 }, ... ] }
// اعتبارسنجی: key ∈ ServiceCategory::values() و 0 ≤ percent ≤ 100 → در غیر این صورت
// $this->error(ErrorCodes::ERR_VALIDATION_001, ..., 422, 'coverage_percent')
}
```
- در پاسخ `GET /api/v1/admin/insurances` (لیست ادمین) هم `coverage_defaults` هر بیمه ضمیمه شود (با `percentMapForMany`، بدون N+1).
- در `GET /api/v1/insurances` و در `pricingPayload()` (خروجی `GET /api/v1/insurance-pricing`) هم برای هر بیمه کلید `coverage_defaults: { outpatient: 30, inpatient: 0 }` اضافه شود — پنل پزشک از همین برای پیشفرض استفاده میکند.
### ۴. درصدهای قرارداد در سطح نوع خدمت
Entity جدید `src/Insurance/Entity/TenantInsuranceCategoryCoverage.php` (`tenant_insurance_category_coverage`): `tenant_insurance_id`, `service_category`, `coverage_percent`, `updated_at` با `UniqueConstraint` روی دو ستون اول.
- در `POST/PATCH /api/v1/billing/tenant-insurances` بدنه یک آرایهٔ اختیاری بگیرد:
```json
{ "insurance_id": 3, "kind": "basic",
"category_coverages": [ { "key": "outpatient", "coverage_percent": 70 },
{ "key": "inpatient", "coverage_percent": 30 } ] }
```
- اگر `category_coverages` ارسال نشد → **هیچ ردیفی ساخته نشود**؛ محاسبه به پیشفرض ادمین برمیگردد (fallback زنده، نه کپی). این مهم است: با تغییر قوانین بیمه در پنل ادمین، قراردادهایی که override نکردهاند خودبهخود بهروز میشوند.
- `GET /api/v1/billing/tenant-insurances` برای هر قرارداد برگرداند:
```json
{ "coverage_percent": 70,
"category_coverages": { "outpatient": 70, "inpatient": 30 },
"category_coverage_source": { "outpatient": "override", "inpatient": "admin_default" } }
```
تا فرانت بتواند نشان دهد کدام مقدار از تنظیمات مرکزی آمده است.
- مجوز override: همان `insurances.update` که در کنترلر با `secretaryAccess`/`clinicDoctorAccess` چک میشود؛ اگر کاربر مجوز ندارد و `category_coverages` فرستاده، `ERR_FORBIDDEN_001` برگردد.
- `TenantInsuranceCleanupService` (حذف قرارداد) باید ردیفهای این جدول را هم پاک کند.
### ۵. نوع خدمت روی `ServiceItem`
- ستون جدید `service_category` روی `src/ClinicService/Entity/ServiceItem.php` با `enumType: ServiceCategory::class` و پیشفرض `outpatient`؛ در `toArray()` هم برگردد.
- ویزیت (که آیتم سرویس نیست) **همیشه `outpatient`** است؛ این را بهصورت ثابت در `PatientService::calculateFinalPrice()` و `InvoiceService::createFromSession()` اعمال کن، نه با مقدار جادویی پراکنده.
- در `ServiceItemFormModal.tsx` یک `SearchableSelect` (طبق قاعدهٔ پروژه: هرگز `