A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.
Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.
Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
24 KiB
طول درمان و پرونده درمان چندجلسهای (فاز اول: لیزر)
پروژه
clinicpro (backend + پنل ادمین). هیچ تغییری در nobat724_front و clinic-pro-tauri لازم نیست.
زمینه
این سند خروجی یک جلسه grilling است. ۲۳ تصمیم قفل شد، شش ADR و یک glossary نوشته شد. پیش از شروع اینها را بخوان:
clinicpro/CONTEXT.md— واژگان رسمی این دامنهclinicpro/docs/adr/0001-treatment-sessions-are-not-appointments.mdclinicpro/docs/adr/0002-treatment-areas-are-snapshotted.mdclinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.mdclinicpro/docs/adr/0004-session-parameters-are-json-keyed-by-resource-type.mdclinicpro/docs/adr/0005-treatment-workflows-are-tagged-services.mdclinicpro/docs/adr/0006-clinical-record-is-separate-from-the-visit-record.mdclinicpro/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 تصمیمگیری شدهاند. اجرایشان کن، دوباره طراحی نکن. اگر جایی از کد با یکی از اینها تناقض داشت، متوقف شو و بپرس؛ خودت تصمیم را عوض نکن.
- حوزه فعالیت از
Specialtyجداست.Specialtyقرارداد سایت عمومی است و دست نمیخورد. - هر کلینیک یک حوزه فعالیت دارد. نال یعنی تنظیمنشده و رفتار امروز.
- جلسه درمان موجودیت مستقل است، نه
Appointment. - زمانبندی با لیست صریح گامها. فاصله ثابت نداریم.
- دوره پایان مشخص دارد. دوره بیپایان وجود ندارد.
- همه جلسات از ابتدا ساخته میشوند، ولی فقط جلسه بعدی نوبت میگیرد.
- ناحیه درمان همان
CatalogCategoryاست. هیچ entity جدیدی برای ناحیه ساخته نشود. - نواحی از دسته سرویس مشتق میشوند، هنگام رزرو انتخاب نمیشوند.
- فهرست نواحی هنگام ساخت پرونده قفل میشود (snapshot).
- پزشک ناظرِ منبع روی نوبت مینشیند. پرسنل انجام میدهد.
- نوبتی که منبع دارد
active_slot_keyندارد. حفاظت فقط باOccupancyBucket. TreatmentProtocolموجودیت جداست، یکبهیک باServiceItem.- پارامترهای دستگاه در JSON، تعریفشان روی
ResourceType. - Workflow با tagged service. موتور دادهمحور نداریم.
- حوزه فعالیت را فقط ادمین پلتفرم میسازد.
- جلسه وضعیت مستقل دارد و نوبت را همگام میکند. بستن جلسه با ناحیه ناتمام مجاز است.
TreatmentSessionبالینی است،PatientSessionمالی. هیچ فیلد پولی روی جلسه درمان.- قیمت هر جلسه با قیمت روز. پکیج قیمت نداریم. قیمت هیچوقت قفل نمیشود.
- جلسه بعد پیشنهاد میشود، منشی تأیید میکند. رزرو خودکار بدون انسان نداریم.
- حوزه فعالیت فقط workflow را انتخاب میکند. داشبورد اختصاصی بهازای تخصص نداریم.
- سررسید هر جلسه نسبی به تاریخ واقعی جلسه قبل است.
- no-show جلسه را نمیسوزاند. تعداد جلسات ثابت میماند.
- زمان واقعی جدا ثبت میشود.
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 ...) گمراهکننده است.
۵. رزرو منبع مستقل از پزشک
این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.
آنچه با داده تأیید شد:
- دو مسیر رزرو داریم و هیچکدام ردیف دیگری نمیسازد.
مسیر پنل روی
appointments.resource_idمینشیند، مسیر hold رویresource_occupancy. bookAtomicallyروی پزشک قفل میگیرد وisSlotTakenتداخل بازهای را فقط روی پزشک میسنجد. منبع در آن کوئری نیست.- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶، کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است.
- برنامه هفتگی پزشک مانع نیست؛
resolveSlotLocationIdفقطnullبرمیگرداند.
پنج تغییر:
۱. مسیر پنل هنگام رزرو ResourceOccupancy بسازد، همانطور که HoldService میسازد.
منطق مشترک در یک سرویس باشد، در دو جا کپی نشود.
۲. لغو یا انقضای نوبت، ردیف اشغال را released کند.
۳. در Appointment::refreshActiveSlotKey() وقتی منبع هست کلید null بماند:
$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:
#[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.
صفحهها و تغییرها:
- فرم سرویس — سوییچ «طول درمان». روشن که شد: جدول گامها (شماره و فاصله از جلسه قبل)،
SearchableSelectچندتایی پرسنل مجاز،SearchableSelectپزشک ناظر. - تنظیمات کلینیک —
SearchableSelectحوزه فعالیت. - صفحه نوع منابع — ویرایشگر
fieldSchema. افزودن و حذف فیلد، انتخاب نوع، گزینهها. - پنل ادمین پلتفرم — CRUD حوزههای فعالیت با نشان «workflow دارد / ندارد».
- فهرست پروندههای درمان — نام بیمار، سرویس، جلسه چندم از چند، وضعیت، سررسید بعدی، اپراتور.
- صفحه اجرای جلسه — کارت هر ناحیه با دکمه شروع، تایمر زنده، فرم داینامیک از
fieldSchema، دکمه اتمام ناحیه، دکمه لغو ناحیه. پایین صفحه یادداشت کلی و دکمه اتمام جلسه با هشدار نواحی ناتمام. اسکرینشاتهای مرجع در تیکت این کار هست. - صف «جلسات بدون نوبت» — جلساتی که
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 جدید نوشته شود.