diff --git a/.claude/prompt/laser-treatment-plan.md b/.claude/prompt/laser-treatment-plan.md new file mode 100644 index 00000000..c68f26cb --- /dev/null +++ b/.claude/prompt/laser-treatment-plan.md @@ -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` باشد. `