feat(docs): add laser treatment plan and related ADRs for multi-session treatments

This commit is contained in:
hamed
2026-08-06 15:23:06 +03:30
parent 1a07dad17c
commit 1d43475724
70 changed files with 15001 additions and 3234 deletions
+419
View File
@@ -0,0 +1,419 @@
# طول درمان و پرونده درمان چندجلسه‌ای (فاز اول: لیزر)
## پروژه
`clinicpro` (backend + پنل ادمین). هیچ تغییری در `nobat724_front` و `clinic-pro-tauri` لازم نیست.
## زمینه
این سند خروجی یک جلسه grilling است. ۲۳ تصمیم قفل شد، شش ADR و یک glossary نوشته شد.
پیش از شروع این‌ها را بخوان:
- `clinicpro/CONTEXT.md` — واژگان رسمی این دامنه
- `clinicpro/docs/adr/0001-treatment-sessions-are-not-appointments.md`
- `clinicpro/docs/adr/0002-treatment-areas-are-snapshotted.md`
- `clinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md`
- `clinicpro/docs/adr/0004-session-parameters-are-json-keyed-by-resource-type.md`
- `clinicpro/docs/adr/0005-treatment-workflows-are-tagged-services.md`
- `clinicpro/docs/adr/0006-clinical-record-is-separate-from-the-visit-record.md`
- `clinicpro/docs/architecture/resource-first-model.md`
از واژگان `CONTEXT.md` استفاده کن. مترادف نساز.
## هدف
کلینیک بتواند سرویسی تعریف کند که درمانش چند جلسه طول می‌کشد، و سیستم برای هر بیمار
پرونده درمان بسازد، جلسات را بشمارد، سررسید جلسه بعد را حساب کند، و اپراتور بتواند
برای هر ناحیه بدن، دستگاه و پارامترهایش را ثبت کند.
فاز اول فقط لیزر. ولی هیچ‌جای کد نباید کلمه «لیزر» را بداند، جز پیاده‌سازی workflow.
## آنچه از قبل هست و باید استفاده شود
| چیز | کجا | نکته |
|---|---|---|
| دسته‌بندی گرافی خدمات | `CatalogCategory` + `CatalogCategoryInclude` | نواحی بدن همین‌جا تعریف می‌شوند |
| بستار گذرا و تشخیص تعارض | `CategoryClosureResolver` | `descendants()` و `overlaps()` |
| سرویس | `ServiceItem` با `catalogCategory` | قیمت و مدت اینجاست |
| نوع منبع داینامیک | `ResourceType` | کدهای سیستمی `doctor` / `staff` / `room` |
| منبع با پزشک ناظر | `ClinicResource.supervisor` | تعریف شده ولی در مسیر رزرو خوانده نمی‌شود |
| اشغال منبع در سطح دیتابیس | `OccupancyBucket` | `uniq_bucket_resource_seat(resource_id, bucket_at, seat)` |
| پرسنل و نقش | `ClinicStaff` + `ROLE_STAFF` | `ClinicStaff.user` اختیاری است |
| داشبورد پرسنل | `GET /api/v1/dashboard/staff` | فیلتر روی `a.staff` |
| صفحه پرسنل | `assets/admin/pages/StaffMyServicesPage.tsx` | فقط سرویس‌های تخصیص‌یافته |
| مراجعه مالی | `PatientSession` | در `AppointmentConfirmationService::onConfirmed` ساخته می‌شود |
## آنچه نیست
هیچ موجودیتی برای پرونده درمان، جلسه درمان، ثبت ناحیه، پروتکل، یا حوزه فعالیت.
`ServiceItem.sessionCount` هست ولی هیچ منطقی از آن استفاده نمی‌کند — عملاً مرده.
---
## تصمیم‌های قفل‌شده
این‌ها در جلسه grilling تصمیم‌گیری شده‌اند. اجرایشان کن، دوباره طراحی نکن.
اگر جایی از کد با یکی از این‌ها تناقض داشت، **متوقف شو و بپرس**؛ خودت تصمیم را عوض نکن.
1. **حوزه فعالیت از `Specialty` جداست.** `Specialty` قرارداد سایت عمومی است و دست نمی‌خورد.
2. **هر کلینیک یک حوزه فعالیت دارد.** نال یعنی تنظیم‌نشده و رفتار امروز.
3. **جلسه درمان موجودیت مستقل است**، نه `Appointment`.
4. **زمان‌بندی با لیست صریح گام‌ها.** فاصله ثابت نداریم.
5. **دوره پایان مشخص دارد.** دوره بی‌پایان وجود ندارد.
6. **همه جلسات از ابتدا ساخته می‌شوند**، ولی فقط جلسه بعدی نوبت می‌گیرد.
7. **ناحیه درمان همان `CatalogCategory` است.** هیچ entity جدیدی برای ناحیه ساخته نشود.
8. **نواحی از دسته سرویس مشتق می‌شوند**، هنگام رزرو انتخاب نمی‌شوند.
9. **فهرست نواحی هنگام ساخت پرونده قفل می‌شود** (snapshot).
10. **پزشک ناظرِ منبع روی نوبت می‌نشیند.** پرسنل انجام می‌دهد.
11. **نوبتی که منبع دارد `active_slot_key` ندارد.** حفاظت فقط با `OccupancyBucket`.
12. **`TreatmentProtocol` موجودیت جداست**، یک‌به‌یک با `ServiceItem`.
13. **پارامترهای دستگاه در JSON**، تعریفشان روی `ResourceType`.
14. **Workflow با tagged service.** موتور داده‌محور نداریم.
15. **حوزه فعالیت را فقط ادمین پلتفرم می‌سازد.**
16. **جلسه وضعیت مستقل دارد** و نوبت را هم‌گام می‌کند. بستن جلسه با ناحیه ناتمام مجاز است.
17. **`TreatmentSession` بالینی است، `PatientSession` مالی.** هیچ فیلد پولی روی جلسه درمان.
18. **قیمت هر جلسه با قیمت روز.** پکیج قیمت نداریم. قیمت هیچ‌وقت قفل نمی‌شود.
19. **جلسه بعد پیشنهاد می‌شود، منشی تأیید می‌کند.** رزرو خودکار بدون انسان نداریم.
20. **حوزه فعالیت فقط workflow را انتخاب می‌کند.** داشبورد اختصاصی به‌ازای تخصص نداریم.
21. **سررسید هر جلسه نسبی به تاریخ واقعی جلسه قبل است.**
22. **no-show جلسه را نمی‌سوزاند.** تعداد جلسات ثابت می‌ماند.
23. **زمان واقعی جدا ثبت می‌شود.** `slotStart` و `slotEnd` هرگز بازنویسی نمی‌شوند.
---
## وظایف
هر وظیفه را جداگانه پیاده کن، تست بنویس، و بعد سراغ بعدی برو.
### ۱. حوزه فعالیت کلینیک
موجودیت جدید در `src/PracticeDomain/Entity/PracticeDomain.php`:
```php
// جدول سراسری (global) — نه per-tenant. در GlobalTables ثبت شود.
id, uuid, code (unique), name, sort_order, active, created_at, updated_at
```
`code` پایدار است و workflow به آن bind می‌شود. بعد از ساخت قابل ویرایش نیست.
روی `Clinic` یک `ManyToOne` نال‌پذیر اضافه کن:
```php
#[ORM\ManyToOne(targetEntity: PracticeDomain::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?PracticeDomain $practiceDomain = null;
```
migration نباید مقدار پیش‌فرض برای کلینیک‌های موجود بگذارد. نال بماند.
اندپوینت‌ها:
- `GET /api/v1/practice-domains` — فهرست فعال‌ها. برای همه نقش‌های پنلی.
- `POST /api/v1/admin/practice-domains` — فقط `ROLE_ADMIN`.
- `PATCH /api/v1/admin/practice-domains/{uuid}` — فقط `name` و `active` و `sort_order`.
- `PATCH /api/v1/clinic/{uuid}/practice-domain` — مدیر کلینیک انتخاب می‌کند.
در پاسخ هر حوزه یک فیلد `has_workflow` بگذار که از `TreatmentWorkflowRegistry` می‌آید.
پنل ادمین پلتفرم باید بتواند نشان دهد کدام حوزه هنوز workflow ندارد.
seed اولیه با migration: `beauty` = «کلینیک زیبایی».
### ۲. پروتکل درمان
سه موجودیت در `src/Treatment/Entity/`:
```php
// TreatmentProtocol — وجود این ردیف یعنی سوییچ «طول درمان» روشن است
id, uuid, service_item_id (unique, ON DELETE CASCADE),
supervisor_doctor_id (nullable), active, created_at, updated_at
// TreatmentProtocolStep — گام‌های دوره
id, protocol_id, step_number, offset_days, created_at
// UNIQUE(protocol_id, step_number)
// offset_days یعنی «فاصله از جلسهٔ قبل»، نه از شروع دوره. گام ۱ همیشه offset_days = 0.
// TreatmentProtocolStaff — پرسنل مجاز به انجام
id, protocol_id, staff_id
// UNIQUE(protocol_id, staff_id)
```
قواعد اعتبارسنجی:
- حداقل دو گام. پروتکل یک‌جلسه‌ای معنی ندارد؛ آن یعنی سوییچ خاموش.
- `step_number` پیوسته از ۱.
- گام اول `offset_days = 0`. بقیه بزرگ‌تر از صفر.
- حداقل یک پرسنل مجاز.
- `supervisor_doctor_id` باید پزشکِ همان محیط باشد.
`ServiceItem::$sessionCount` را `@deprecated` علامت بزن، از `toArray()` بیرون **نبر**
(پنل و تایپ TS به آن وابسته‌اند) ولی هیچ منطق جدیدی از آن نخوان. تعداد جلسات
همیشه `count(protocol.steps)` است.
اندپوینت‌ها زیر `/api/v1/clinic-services/{serviceUuid}/treatment-protocol`:
`GET`، `PUT` (کل پروتکل با گام‌ها و پرسنل یکجا)، `DELETE` (خاموش کردن سوییچ).
### ۳. پرونده و جلسه درمان
```php
// TreatmentCase
id, uuid, entity_type, entity_id, // tenant
patient_record_id, service_item_id, protocol_id,
supervisor_doctor_id (nullable),
status, // active | completed | abandoned
total_sessions, // snapshot از تعداد گام‌ها
opened_at, closed_at (nullable),
created_at, updated_at
// TreatmentCaseArea — snapshot نواحی (تصمیم ۹)
id, case_id, catalog_category_id, name_snapshot, sort_order
// UNIQUE(case_id, catalog_category_id)
// TreatmentSession
id, uuid, case_id, session_number,
appointment_id (nullable, ON DELETE SET NULL),
performed_by_staff_id (nullable), // تصمیم ۲۳
status, // planned | booked | in_progress | done | cancelled | no_show
due_at (nullable), // تخمینی، بعد از هر جلسه بازمحاسبه می‌شود
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(case_id, session_number)
// SessionAreaRecord
id, uuid, session_id, case_area_id,
resource_id (nullable), // دستگاه — در سطح ناحیه
status, // pending | in_progress | completed | skipped
parameters JSON (nullable), // { "energy": 18, "pulse": 3, "shots": 212 }
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(session_id, case_area_id)
```
**هیچ ستون پولی روی این جدول‌ها نگذار.** ADR-0006.
`name_snapshot` روی `TreatmentCaseArea` عمدی است: اگر مدیر بعداً اسم دسته را عوض کند،
سابقه درمان نباید تغییر کند.
نواحی هنگام ساخت پرونده اینطور حساب می‌شوند:
```
leaves = برگ‌های CategoryClosureResolver::descendants(service.catalogCategory)
اگر descendants خالی بود → خودِ service.catalogCategory تنها ناحیه است
```
«برگ» یعنی دسته‌ای که خودش `descendants` ندارد. دسته‌های میانی فقط گروه‌بندی‌اند و
ناحیه درمان نیستند.
### ۴. اتصال پزشک ناظر به مسیر رزرو
در `AppointmentController` بلوکی که پزشک را از منبع استنتاج می‌کند (حدود خط ۴۷۱)
یک fallback اضافه کن:
```php
if ($doctorUuid === '' && $resource->subject() instanceof Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
// جدید:
if ($doctorUuid === '' && $resource->getSupervisor() !== null) {
$doctorUuid = $resource->getSupervisor()->getUuid();
}
```
اگر منبعی نه پزشک است نه ناظر دارد، خطای واضح بده:
«این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید».
پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراه‌کننده است.
### ۵. آزادسازی کلید اسلات برای نوبت‌های منبع‌دار
در `Appointment::refreshActiveSlotKey()`:
```php
$this->activeSlotKey = (!$this->isReserve
&& $this->resource === null // ← شرط جدید
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true))
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
```
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند، وگرنه نوبتی که اول ساخته
و بعد منبعش ست می‌شود کلیدش باقی می‌ماند.
**قبل از این تغییر:** همه مسیرهای ساخت نوبت را فهرست کن و مشخص کن کدام‌ها `resource`
ست نمی‌کنند. آن‌ها بعد از این تغییر همچنان با کلید پزشک محافظت می‌شوند — این درست است،
ولی باید مستند شود که کدام‌ها هستند. ADR-0003 روی همین هشدار داده.
migration لازم نیست؛ ستون بدون تغییر می‌ماند و فقط منطق پرشدنش عوض می‌شود.
### ۶. تعریف فیلد روی نوع منبع
ستون JSON روی `ResourceType`:
```php
#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
private ?array $fieldSchema = null;
```
قالب هر فیلد:
```json
{ "key": "energy", "label": "انرژی", "type": "select",
"options": [7, 8, 9, 10, 12, 14, 16, 18], "required": true, "sort_order": 1 }
```
`type` مجاز: `select` و `number` و `text`. همین سه تا، نه بیشتر.
اعتبارسنجی مقادیر `SessionAreaRecord.parameters` از همین schema می‌آید. یک سرویس
`FieldSchemaValidator` بنویس که هم در ذخیره اندپوینت استفاده شود هم در تست.
کلیدهای ناشناخته که در schema نیستند رد شوند، نه اینکه بی‌صدا ذخیره شوند.
seed اولیه: یک `ResourceType` با کد `laser_device` و نام «دستگاه لیزر» و همان سه فیلد
بالا به‌علاوه `shots` از نوع `number`. `is_system = false` تا مدیر بتواند ویرایشش کند.
### ۷. Workflow قابل توسعه
```php
// src/Treatment/Workflow/TreatmentWorkflow.php
#[AutoconfigureTag('app.treatment_workflow')]
interface TreatmentWorkflow
{
public function supports(?string $practiceDomainCode): bool;
/** بعد از تأیید اولین نوبتِ یک سرویسِ پروتکل‌دار */
public function openCase(Appointment $appointment, TreatmentProtocol $protocol): TreatmentCase;
/** بعد از بسته شدن یک جلسه — سررسید جلسه بعد را حساب می‌کند */
public function onSessionFinished(TreatmentSession $session): void;
}
```
`TreatmentWorkflowRegistry` با `#[TaggedIterator('app.treatment_workflow')]` ساخته شود و
اولین workflow ای که `supports()` بدهد را برگرداند. اگر هیچ‌کدام، `DefaultTreatmentWorkflow`
که رفتار عمومی دارد.
**هسته نباید بداند لیزر چیست.** `AppointmentConfirmationService` فقط این را می‌کند:
```
اگر سرویسِ نوبت پروتکل فعال دارد و بیمار پروندهٔ باز برای همان سرویس ندارد
→ registry->for(clinic.practiceDomain?.code)->openCase(...)
```
`LaserTreatmentWorkflow` در فاز اول تقریباً همان `DefaultTreatmentWorkflow` است.
جدا نگهش دار حتی اگر خالی باشد — نقطه اتصال آینده است.
### ۸. سررسید و جلسه بعد
قاعده محاسبه (تصمیم ۲۱):
```
due_at(session n) = finished_at(session n-1) + protocol.step[n].offset_days
جلسه ۱ سررسید ندارد؛ تاریخش همان نوبت اول است.
```
بعد از `finished_at` شدن هر جلسه، فقط `due_at` **جلسه بعدی** بازمحاسبه شود، نه کل دوره.
جلسات دورتر تخمین قبلی‌شان را نگه می‌دارند تا نوبتشان برسد.
no-show (تصمیم ۲۲): جلسه به `no_show` می‌رود، `session_number` عوض نمی‌شود،
`total_sessions` عوض نمی‌شود. یک جلسه جایگزین با همان شماره **ساخته نمی‌شود**؛ همان جلسه
دوباره به `planned` برمی‌گردد و `due_at` از تاریخ جلسه قبلِ **انجام‌شده** حساب می‌شود.
رزرو جلسه بعد (تصمیم ۱۹) **خودکار نیست**. اندپوینت پیشنهاد بده:
```
GET /api/v1/treatment-sessions/{uuid}/slot-suggestions
→ اسلات‌های آزاد منبع، از due_at به بعد، با استفاده از ResourceFreeTimeCalculator
```
منشی یکی را انتخاب می‌کند و مسیر عادی ساخت نوبت اجرا می‌شود، سپس نوبت به جلسه وصل می‌شود.
### ۹. اندپوینت‌های اجرای جلسه
همه زیر `ROLE_STAFF` یا بالاتر. علاوه بر نقش، بررسی کن پرسنل واقعاً به این جلسه دسترسی
دارد — الگویش در `DashboardController::staff` هست (`findActiveByUserAndEntity`).
```
GET /api/v1/dashboard/staff/treatment-sessions جلسات امروز پرسنل
GET /api/v1/treatment-sessions/{uuid} جزئیات + نواحی + فیلدهای دستگاه
POST /api/v1/treatment-sessions/{uuid}/start → in_progress، started_at
POST /api/v1/treatment-sessions/{uuid}/finish → done، finished_at، note
POST /api/v1/session-areas/{uuid}/start → in_progress، started_at
POST /api/v1/session-areas/{uuid}/complete → completed، parameters، finished_at
POST /api/v1/session-areas/{uuid}/skip → skipped
GET /api/v1/treatment-cases فهرست پرونده‌ها با فیلتر
GET /api/v1/treatment-cases/{uuid} پرونده + همه جلسات
```
هم‌گام‌سازی وضعیت نوبت (تصمیم ۱۶):
```
session start → Appointment::STATUS_SALON
session finish → Appointment::STATUS_COMPLETED
```
از `ALLOWED_TRANSITIONS` عبور کن، مستقیم `setStatus` نزن. اگر گذار مجاز نبود، جلسه را
ببند ولی نوبت را دست نزن و لاگ بگذار — خطای ۵۰۰ نده.
بستن جلسه با ناحیه ناتمام **مجاز است** (تصمیم ۱۶). فقط در پاسخ تعداد ناتمام‌ها را برگردان
تا پنل هشدار نشان دهد.
`started_at` و `finished_at` هرگز روی `Appointment` نوشته نشوند (تصمیم ۲۳).
### ۱۰. پنل ادمین
قواعد اجباری این پروژه:
- هر `select` باید `components/ui/SearchableSelect` باشد. `<select>` بومی ممنوع.
- هر بولین باید `components/ui/Switch` باشد. checkbox بومی ممنوع.
- صفحه جدید با تم و Layout و کامپوننت‌های موجود ساخته شود. طراحی جدید نکن.
- تاریخ‌ها شمسی. متن‌ها فارسی. RTL.
صفحه‌ها و تغییرها:
1. **فرم سرویس** — سوییچ «طول درمان». روشن که شد: جدول گام‌ها (شماره و فاصله از جلسه قبل)،
`SearchableSelect` چندتایی پرسنل مجاز، `SearchableSelect` پزشک ناظر.
2. **تنظیمات کلینیک**`SearchableSelect` حوزه فعالیت.
3. **صفحه نوع منابع** — ویرایشگر `fieldSchema`. افزودن و حذف فیلد، انتخاب نوع، گزینه‌ها.
4. **پنل ادمین پلتفرم** — CRUD حوزه‌های فعالیت با نشان «workflow دارد / ندارد».
5. **فهرست پرونده‌های درمان** — نام بیمار، سرویس، جلسه چندم از چند، وضعیت، سررسید بعدی، اپراتور.
6. **صفحه اجرای جلسه** — کارت هر ناحیه با دکمه شروع، تایمر زنده، فرم داینامیک از `fieldSchema`،
دکمه اتمام ناحیه، دکمه لغو ناحیه. پایین صفحه یادداشت کلی و دکمه اتمام جلسه با هشدار
نواحی ناتمام. اسکرین‌شات‌های مرجع در تیکت این کار هست.
7. **صف «جلسات بدون نوبت»** — جلساتی که `status = planned` و `due_at` گذشته یا نزدیک است.
از هر ردیف مستقیم به انتخاب اسلات پیشنهادی.
تایمر فقط نمایشی است. زمان معتبر همان `started_at` و `finished_at` سرور است.
---
## ترتیب اجرا
```
۱ → ۲ → ۳ → ۶ → ۷ → ۴ → ۵ → ۸ → ۹ → ۱۰
```
وظیفه ۵ (کلید اسلات) روی پرترافیک‌ترین جدول سیستم است. بعد از وظیفه ۴ انجامش بده و
قبل و بعدش تست رگرسیون رزرو را کامل اجرا کن.
## تست
- unit برای `CategoryClosureResolver` در حالت برگ‌یابی نواحی
- unit برای محاسبه `due_at` با گام‌های نامساوی (سناریوی بوتاکس: ۰، ۱۵، ۳۰، ۳۰)
- unit برای `FieldSchemaValidator` با کلید ناشناخته و مقدار خارج از `options`
- functional: پزشک ناظر مشترک بین دو دستگاه، دو رزرو هم‌ساعت، هر دو باید موفق شوند
- functional: اتاق با `capacity = 3`، سه رزرو هم‌ساعت، هر سه باید موفق شوند
- functional: چرخه کامل یک دوره سه‌جلسه‌ای شامل یک no-show
- رگرسیون: رزرو بدون منبع همچنان با کلید پزشک از دوبار رزرو جلوگیری کند
## خارج از دامنه فاز اول
- seeder هوشمند با AI برای پیشنهاد دسته‌بندی و منابع
- گزارش‌های تحلیلی روی `parameters` (کوئری JSON بدون ایندکس)
- حوزه فعالیت دوم غیر از زیبایی
- داشبورد اختصاصی به‌ازای هر تخصص
- قیمت پکیج و قفل قیمت
## مستندسازی
طبق قانون ثابت این پروژه، `docs/api/*` در همان session به‌روز شود.
اگر تصمیمی در حین اجرا عوض شد، ADR مربوطه به‌روز شود یا ADR جدید نوشته شود.