# فاز ۱ — حوزه در سطح محیط و نصب پیش‌فرض‌های دندانپزشکی > پیش‌نیاز: ندارد. > خروجی قابل تست: مطب یا کلینیک، حوزهٔ دندانپزشکی را انتخاب می‌کند و کاتالوگ خدمات دندانپزشکی‌اش ساخته می‌شود. --- ## ۱. هدف سه چیز: ۱. مطب تک‌پزشک هم بتواند حوزهٔ فعالیت انتخاب کند، نه فقط کلینیک. ۲. با انتخاب دندانپزشکی، دسته‌بندی‌ها و خدمات و نوع منبع یونیت و پروتکل‌ها ساخته شوند. ۳. هر خدمت دندانی بداند روی دندان انجام می‌شود یا روی فک یا روی کل دهان. --- ## ۲. مدل داده ### ۲.۱ تغییر روی دامنهٔ موجود روی `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` مسدودکننده است. **نصب خودکار روی محیطی که کاربر نمی‌خواست.** مهار: فقط وقتی کاتالوگ خالی است، و ردیف‌ها با قیمت صفر و قابل غیرفعال کردن. **تعریف «کاتالوگ خالی» ناپایدار.** اگر کلینیکی یک دستهٔ آزمایشی ساخته باشد، نصب خودکار انجام نمی‌شود و کاربر گیج می‌شود. مهار: پنل همیشه وضعیت نصب و دکمه را نشان می‌دهد، پس مسیر دوم همیشه در دسترس است.