# فاز ۱ — حوزه در سطح محیط و نصب پیش‌فرض‌های دندانپزشکی

> پیش‌نیاز: ندارد.
> خروجی قابل تست: مطب یا کلینیک، حوزهٔ دندانپزشکی را انتخاب می‌کند و کاتالوگ خدمات دندانپزشکی‌اش ساخته می‌شود.

---

## ۱. هدف

سه چیز:

۱. مطب تک‌پزشک هم بتواند حوزهٔ فعالیت انتخاب کند، نه فقط کلینیک.
۲. با انتخاب دندانپزشکی، دسته‌بندی‌ها و خدمات و نوع منبع یونیت و پروتکل‌ها ساخته شوند.
۳. هر خدمت دندانی بداند روی دندان انجام می‌شود یا روی فک یا روی کل دهان.

---

## ۲. مدل داده

### ۲.۱ تغییر روی دامنهٔ موجود

روی `Doctor` یک رابطهٔ اختیاری به `PracticeDomain` اضافه می‌شود:

```
doctors.practice_domain_id  → practice_domains.id   nullable, ON DELETE SET NULL
```

مقدار `NULL` یعنی «تنظیم نشده» و همان رفتار امروز است. هرگز خطا نیست.

خواندن حوزهٔ محیط باید از یک نقطه باشد، نه دو `if` پراکنده:

```
src/PracticeDomain/Service/PracticeDomainResolver.php
```

ورودی‌اش `EntityContext` است و خروجی‌اش کد حوزه یا `null`.
دلیل جداکردنش: هر کسی که حوزه را لازم دارد نباید بداند محیط کلینیک است یا مطب.

### ۲.۲ موجودیت‌های تازه در دامنهٔ Dental

همه در `src/Dental/Entity`.

#### `DentalServiceProfile` — جدول `dental_service_profiles`

توسعهٔ یک‌به‌یک روی `ServiceItem`.

| ستون | نوع | توضیح |
|---|---|---|
| `id` | int | |
| `uuid` | string 36 | |
| `entity_type` و `entity_id` | | جفت محیط، از `TenantOwnedTrait` |
| `service_item_id` | int | یکتا، `ON DELETE CASCADE` |
| `target_scope` | string 20 | دامنهٔ هدف |
| `pricing_basis` | string 20 | مبنای واحد قیمت |
| `tooth_scope` | string 20 | دائمی، شیری، یا هر دو |
| `requires_lab` | bool | فاز ۵ از آن استفاده می‌کند |
| `created_at` و `updated_at` | int | |

مقدار `target_scope` تعیین می‌کند فرم ثبت خدمت در ویزیت چه چیزی بپرسد:

| مقدار | معنی | ورودی لازم در ویزیت |
|---|---|---|
| `none` | خدمت بدون هدف | هیچ |
| `tooth` | یک دندان | شمارهٔ دندان |
| `tooth_surface` | سطوح یک دندان | شمارهٔ دندان و حداقل یک سطح |
| `quadrant` | یک ناحیهٔ فک | کد ناحیه از ۱ تا ۴ |
| `arch` | یک فک | بالا یا پایین |
| `mouth` | کل دهان | هیچ |

مقادیر `pricing_basis`:

```
per_tooth | per_surface | per_canal | per_unit | per_arch | per_quadrant | per_session | flat
```

مقادیر `tooth_scope`:

```
any | permanent_only | primary_only
```

هر سه به‌صورت `enum` PHP در `src/Dental/Enum` تعریف می‌شوند و در ستون به‌صورت رشته ذخیره می‌شوند.
دلیل رشته‌بودن: افزودن مقدار تازه نباید migration بخواهد.

#### `PresetInstall` — جدول `dental_preset_installs`

| ستون | نوع | توضیح |
|---|---|---|
| `id` | int | |
| `uuid` | string 36 | |
| `entity_type` و `entity_id` | | جفت محیط |
| `preset_code` | string 40 | مثلاً `dental` |
| `preset_version` | int | نسخهٔ قالبی که نصب شد |
| `template_key` | string 80 | کلید ثابت ردیف در قالب |
| `target_type` | string 30 | `catalog_category`, `service_item`, `resource_type`, `treatment_protocol` |
| `target_id` | int | شناسهٔ ردیف واقعی ساخته‌شده |
| `installed_at` | int | |

یکتایی: `(entity_type, entity_id, preset_code, template_key)`.

این جدول تنها مبنای idempotency است.
اجرای دوم seed، هر کلید قالبی را که اینجا ثبت شده دوباره نمی‌سازد.

**چرا این جدول و نه یک ستون `origin` روی کاتالوگ:**
جدول‌های کاتالوگ مشترک همهٔ حوزه‌هایند.
یک ستون منشأ روی آن‌ها یعنی هر حوزهٔ بعدی هم چیزی به جدول مشترک اضافه می‌کند.
جدول نگاشت با حذف ماژول تمیز برداشته می‌شود.

---

## ۳. قالب پیش‌فرض

مسیر: `src/Dental/Preset/DentalPreset.php`

یک کلاس `final` با آرایه‌های ثابت و یک `const VERSION`.
محتوایش در [preset-content.md](preset-content.md).

ساختار هر بخش:

```
GROUPS     : [ template_key, name, sort_order, parent_key|null ]
SERVICES   : [ template_key, name, group_key, duration_minutes, session_count,
               target_scope, pricing_basis, tooth_scope, requires_lab ]
RESOURCE_TYPES : [ template_key, code, name, field_schema|null ]
PROTOCOLS  : [ template_key, service_key, session_count, interval_days ]
```

قیمت همهٔ خدمات صفر است.
دلیل: قیمت جعلی که کسی اصلاحش نکند، به بیمار نشان داده می‌شود.
صفر بودن در پنل قابل دیدن و قابل فیلتر کردن است.

`VERSION` عدد صحیح است و با هر تغییر محتوا یکی زیاد می‌شود.
نسخه در `dental_preset_installs` ثبت می‌شود تا بعداً بشود گفت کدام محیط با کدام نسخه نصب شده.

---

## ۴. سرویس نصب

مسیر: `src/Dental/Service/DentalPresetInstaller.php`

```
install(EntityContext $context, bool $force = false): PresetInstallReport
preview(EntityContext $context): PresetInstallReport
```

قواعد:

- همه‌چیز در یک تراکنش. نصب نیمه‌کاره نداریم.
- هر ردیف قبل از ساخت، در `dental_preset_installs` جستجو می‌شود.
- ردیفی که کلیدش قبلاً نصب شده، دست نمی‌خورد. حتی اگر مدیر نامش را عوض کرده باشد.
- `preview` هیچ چیزی نمی‌نویسد و همان گزارش را بدون ساخت برمی‌گرداند.
- بخش سازمانی: اگر محیط هیچ `ServiceSection` نداشت، یکی با نام «دندانپزشکی» ساخته می‌شود، وگرنه اولین بخش فعال استفاده می‌شود. دلیل: `ServiceItem` بدون بخش `NOT NULL` نمی‌شود.

`PresetInstallReport` یک شیء ساده است با تعداد ساخته‌شده و تعداد ردشده به تفکیک نوع.

### تصمیم لحظهٔ اجرا

هنگام `PATCH` روی کلینیک یا پزشک، اگر حوزه به دندانپزشکی تغییر کرد:

```
کاتالوگ محیط خالی است  → نصب خودکار، بدون پرسش
کاتالوگ محیط داده دارد → هیچ چیز ساخته نمی‌شود، پرچم «پیش‌فرض نصب‌نشده» برای پنل برمی‌گردد
```

تعریف «خالی»: هیچ `ServiceItem` و هیچ `CatalogCategory` فعالی در آن محیط نباشد.

---

## ۵. API

مستندات در `docs/api/dental.md` نوشته می‌شود. فایل تازه است.

```
GET  /api/v1/dental/preset/status
     → { installed: bool, installed_version: int|null,
         current_version: int, catalog_empty: bool }

GET  /api/v1/dental/preset/preview
     → { groups: n, services: n, resource_types: n, protocols: n, skipped: n }

POST /api/v1/dental/preset/install
     → همان گزارش، بعد از نصب واقعی
```

دسترسی: `ROLE_CLINIC` یا `ROLE_DOCTOR`. محیط از `EntityContextResolver` می‌آید، نه از بدنهٔ درخواست.

خطاها:

| کد | HTTP | حالت |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | حوزهٔ فعالیت محیط دندانپزشکی نیست |
| `ERR_FORBIDDEN_001` | 403 | نقش مجاز نیست |

اندپوینت خدمت هم گسترش پیدا می‌کند:

```
POST  /api/v1/service-item        بدنه کلید اختیاری dental می‌گیرد
PATCH /api/v1/service-item/{uuid} همان
GET   /api/v1/service-item/{uuid} پاسخ کلید dental دارد اگر پروفایل داشته باشد
```

`docs/api/clinic-services.md` همان جلسه به‌روز می‌شود.

---

## ۶. پنل ادمین

### صفحهٔ حوزهٔ فعالیت

فایل: `assets/admin/pages/PracticeDomainSettingsPage.tsx`

- برای کاربر پزشک هم کار کند، نه فقط کلینیک.
- بعد از انتخاب دندانپزشکی، وضعیت نصب پیش‌فرض نشان داده شود.
- اگر نصب نشده، کارت با پیش‌نمایش تعداد و دکمهٔ نصب.
- بعد از نصب موفق، پیام با تعداد ردیف ساخته‌شده و لینک به صفحهٔ خدمات.

### فرم خدمت

فایل: `assets/admin/pages/ClinicServicesPage.tsx`

- اگر حوزهٔ محیط دندانپزشکی است، بخش «ویژگی‌های دندانی» در فرم خدمت اضافه شود.
- سه انتخاب: دامنهٔ هدف، مبنای قیمت، نوع دندان. هر سه با `SearchableSelect`.
- سوییچ «نیاز به لابراتوار» با `Switch`.
- برای حوزه‌های دیگر این بخش اصلاً رندر نشود.

---

## ۷. تسک‌ها

| کد | تسک | فایل‌های اصلی | معیار پذیرش |
|---|---|---|---|
| DM1-01 | افزودن `practice_domain_id` به `Doctor` با migration | `src/Doctor/Entity/Doctor.php`, `migrations/` | migration روی دیتابیس تست اجرا و برگشت می‌خورد |
| DM1-02 | `PracticeDomainResolver` بر اساس `EntityContext` | `src/PracticeDomain/Service/` | تست: محیط کلینیک، محیط پزشک، محیط بدون حوزه |
| DM1-03 | اندپوینت تنظیم حوزه برای پزشک | `src/Doctor/Controller/`, `docs/api/practice-domain.md` | پزشک حوزه را ست می‌کند و در `GET` پروفایل می‌بیند |
| DM1-04 | `enum`های دندانی | `src/Dental/Enum/` | مقدار نامعتبر `AppException` می‌دهد |
| DM1-05 | موجودیت و مخزن `DentalServiceProfile` | `src/Dental/Entity/`, `src/Dental/Repository/` | `TenantSchemaCoverageTest` سبز |
| DM1-06 | موجودیت و مخزن `PresetInstall` | همان | یکتایی کلید قالب در محیط تست می‌شود |
| DM1-07 | فایل قالب `DentalPreset` با محتوای پیش‌نویس | `src/Dental/Preset/` | تست ساختاری: هر خدمت به گروه موجود اشاره می‌کند |
| DM1-08 | `DentalPresetInstaller` با `install` و `preview` | `src/Dental/Service/` | اجرای دوم هیچ ردیف تازه‌ای نمی‌سازد |
| DM1-09 | اتصال نصب خودکار به تغییر حوزه | `src/Clinic/Controller/ClinicController.php` و معادل پزشک | کاتالوگ خالی نصب می‌شود، کاتالوگ پرداده نمی‌شود |
| DM1-10 | سه اندپوینت `preset` | `src/Dental/Controller/DentalPresetController.php` | تست موفق، بدون دسترسی، حوزهٔ اشتباه |
| DM1-11 | گسترش `service-item` برای کلید `dental` | `src/ClinicService/Controller/ClinicServiceController.php` | ساخت خدمت با پروفایل و بدون آن، هر دو کار می‌کند |
| DM1-12 | `docs/api/dental.md` و به‌روزرسانی دو سند موجود | `docs/api/` | مسیرها و خطاها با کد یکی است |
| DM1-13 | کارت نصب پیش‌فرض در صفحهٔ حوزهٔ فعالیت | `assets/admin/pages/PracticeDomainSettingsPage.tsx` | تست: نصب‌نشده، نصب‌شده، حوزهٔ غیر دندانی |
| DM1-14 | بخش ویژگی دندانی در فرم خدمت | `assets/admin/pages/ClinicServicesPage.tsx` | برای حوزهٔ زیبایی رندر نمی‌شود |
| DM1-15 | بازبینی تخصصی فهرست خدمات توسط دندانپزشک | `preset-content.md` | بدون این، فاز ۱ بسته نمی‌شود |

---

## ۸. تست‌ها

- نصب روی محیط خالی: همهٔ ردیف‌ها ساخته می‌شوند.
- نصب دوم: صفر ردیف تازه، گزارش می‌گوید چند تا رد شد.
- نصب روی محیطی که حوزه‌اش دندانپزشکی نیست: خطای ۴۲۲.
- نصب توسط نقش بدون دسترسی: خطای ۴۰۳.
- شکست وسط نصب: هیچ ردیفی باقی نمی‌ماند، تراکنش برگشت می‌خورد.
- محیط پزشک و محیط کلینیک، هر دو مسیر.
- خدمتی که پروفایل دندانی ندارد، در حوزهٔ دندانپزشکی هم بدون خطا کار می‌کند.

---

## ۹. ریسک‌ها

**فهرست خدمات غلط.**
اثرش در همهٔ کلینیک‌های نصب‌کننده پخش می‌شود و جمع کردنش ممکن نیست.
مهار: تسک `DM1-15` مسدودکننده است.

**نصب خودکار روی محیطی که کاربر نمی‌خواست.**
مهار: فقط وقتی کاتالوگ خالی است، و ردیف‌ها با قیمت صفر و قابل غیرفعال کردن.

**تعریف «کاتالوگ خالی» ناپایدار.**
اگر کلینیکی یک دستهٔ آزمایشی ساخته باشد، نصب خودکار انجام نمی‌شود و کاربر گیج می‌شود.
مهار: پنل همیشه وضعیت نصب و دکمه را نشان می‌دهد، پس مسیر دوم همیشه در دسترس است.
