Files
clinicpro/.claude/prompt/laser-treatment-plan.md
T

23 KiB
Raw Blame History

طول درمان و پرونده درمان چندجلسه‌ای (فاز اول: لیزر)

پروژه

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:

// جدول سراسری (global) — نه per-tenant. در GlobalTables ثبت شود.
id, uuid, code (unique), name, sort_order, active, created_at, updated_at

code پایدار است و workflow به آن bind می‌شود. بعد از ساخت قابل ویرایش نیست.

روی Clinic یک ManyToOne نال‌پذیر اضافه کن:

#[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/:

// 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 (خاموش کردن سوییچ).

۳. پرونده و جلسه درمان

// 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 اضافه کن:

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():

$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:

#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
private ?array $fieldSchema = null;

قالب هر فیلد:

{ "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 قابل توسعه

// 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 جدید نوشته شود.