Add dental module phase 4 and phase 5 documentation, including treatment estimates, clinical operations, and preset content

- Introduced phase 4 documentation detailing treatment estimates, data models, state machines, API endpoints, and new dashboard metrics.
- Added phase 5 documentation covering consumables, lab operations, periodontal charts, sterilization cycles, clinical images, and a checklist for professional review.
- Created a preset content document outlining default dental service packages and protocols for clinics.
This commit is contained in:
hamed
2026-08-20 16:21:08 +03:30
parent 601d211d6f
commit 2f030edef1
12 changed files with 1794 additions and 3 deletions
@@ -0,0 +1,273 @@
# فاز ۱ — حوزه در سطح محیط و نصب پیش‌فرض‌های دندانپزشکی
> پیش‌نیاز: ندارد.
> خروجی قابل تست: مطب یا کلینیک، حوزهٔ دندانپزشکی را انتخاب می‌کند و کاتالوگ خدمات دندانپزشکی‌اش ساخته می‌شود.
---
## ۱. هدف
سه چیز:
۱. مطب تک‌پزشک هم بتواند حوزهٔ فعالیت انتخاب کند، نه فقط کلینیک.
۲. با انتخاب دندانپزشکی، دسته‌بندی‌ها و خدمات و نوع منبع یونیت و پروتکل‌ها ساخته شوند.
۳. هر خدمت دندانی بداند روی دندان انجام می‌شود یا روی فک یا روی کل دهان.
---
## ۲. مدل داده
### ۲.۱ تغییر روی دامنهٔ موجود
روی `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` مسدودکننده است.
**نصب خودکار روی محیطی که کاربر نمی‌خواست.**
مهار: فقط وقتی کاتالوگ خالی است، و ردیف‌ها با قیمت صفر و قابل غیرفعال کردن.
**تعریف «کاتالوگ خالی» ناپایدار.**
اگر کلینیکی یک دستهٔ آزمایشی ساخته باشد، نصب خودکار انجام نمی‌شود و کاربر گیج می‌شود.
مهار: پنل همیشه وضعیت نصب و دکمه را نشان می‌دهد، پس مسیر دوم همیشه در دسترس است.