# طول درمان و پرونده درمان چندجلسه‌ای (فاز اول: لیزر) ## پروژه `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 ...`) گمراه‌کننده است. ### ۵. رزرو منبع مستقل از پزشک **این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.** آنچه با داده تأیید شد: - دو مسیر رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد. مسیر پنل روی `appointments.resource_id` می‌نشیند، مسیر hold روی `resource_occupancy`. - `bookAtomically` روی **پزشک** قفل می‌گیرد و `isSlotTaken` تداخل بازه‌ای را فقط روی پزشک می‌سنجد. منبع در آن کوئری نیست. - در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶، کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است. - برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمی‌گرداند. پنج تغییر: ۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همان‌طور که `HoldService` می‌سازد. منطق مشترک در یک سرویس باشد، در دو جا کپی نشود. ۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند. ۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند: ```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()` را صدا بزند. ۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند. تضمین یکتایی از `uniq_bucket_resource_seat` می‌آید که ظرفیت و `seat` را می‌فهمد. ۵. شعبه نوبتِ منبع‌دار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک. **قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدام‌ها منبع ست نمی‌کنند. آن‌ها کلید پزشک و قفل پزشک را نگه می‌دارند. این فهرست باید در گزارش بیاید. migration برای `active_slot_key` لازم نیست. برای ردیف‌های اشغالِ گذشتهٔ مسیر پنل یک 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` باشد. `