The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.
Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.
Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.
`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.
Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.
Adds the test files neither the page nor the case editor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 KiB
Treatment API
Prefixes:
/api/v1/service-item/{uuid}/treatment-protocol·/api/v1/treatment-case[s]·/api/v1/treatment-session[s]·/api/v1/dashboard/staff/…
A treatment protocol is the "طول درمان" of a service: it says a course of that service runs over several sessions, when each one falls due, which doctor supervises it, and which staff may perform it. A service without a protocol row is single-session — the row's existence is the switch, which is why there is no separate boolean.
ServiceItem.session_count is deprecated. It never had logic behind it; session count now comes
from the protocol's step list. The column still appears in service payloads so existing clients do
not break, but nothing should read it.
Superseded design note: an earlier src/Course/ module (CourseProtocol / TreatmentCourse) was
deleted in 65d5831c and its docs removed. It modelled the gap between sessions as one
min/ideal/max triple for the whole course, which cannot express a course whose intervals differ per
session — a botox course is session 1, then +15 days, then monthly. This design stores an explicit
offset per step instead.
The step model
Each step carries offset_days, and that offset is measured from the previous session, not from
the start of the course. The spacing of a laser course is a clinical requirement — hair regrows
relative to the last treatment, not relative to when the file was opened — so a patient who arrives
late shifts the rest of their course rather than getting the next session too early.
| Rule | چرا |
|---|---|
| حداقل ۲ گام | یک گام یعنی سرویس تکجلسهای؛ سوییچ اصلاً نباید روشن باشد |
| حداکثر ۶۰ گام | سقف عقلانی، جلوی ورودی اشتباه را میگیرد |
step_number پیوسته از ۱ |
«جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که گامی جا نیفتاده باشد |
گام ۱ → offset_days = 0 |
لنگر دوره است و «جلسهٔ قبل» ندارد |
گامهای بعدی → offset_days > 0 |
فاصلهٔ صفر یعنی دو جلسه در یک روز |
| حداقل یک پرسنل مجاز | بدون آن هر پرسنلی پای هر دستگاهی مینشیند |
GET /api/v1/service-item/{uuid}/treatment-protocol
Permission: IS_AUTHENTICATED_FULLY, محدود به محیط جاری — سرویس محیط دیگر 404 میگیرد.
data: null یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
وقتی پروتکل هست، یک کلید کمکی هم میآید:
| Field | توضیح |
|---|---|
service_has_resources |
آیا هیچ ResourceServiceOffering برای این سرویس هست |
false رزرو را قفل نمیکند — قاعدهٔ «سرویس بدون offering روی هر منبعی مجاز است» عمدی و
تستشده است. ولی مدیر باید ببیند، وگرنه تازه وقتی اپراتور جلوی بیمار میرسد معلوم میشود هیچ
دستگاهی وصل نشده و فرم درست نمیآید.
Response 200
{
"success": true,
"data": {
"uuid": "8de51c47-ddd1-44ea-bae1-83cf6457b182",
"service_uuid": "cc0b11ec-1c39-45f0-bc1d-5514619bc74a",
"active": true,
"total_sessions": 4,
"supervisor": null,
"steps": [
{ "step_number": 1, "offset_days": 0 },
{ "step_number": 2, "offset_days": 15 },
{ "step_number": 3, "offset_days": 30 },
{ "step_number": 4, "offset_days": 30 }
],
"staff": [
{ "uuid": "53acc523-48a7-4f4a-89b1-745e8e7a69bd", "name": "پرسنل۱" }
]
}
}
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_NOT_FOUND_001 | 404 | سرویس یافت نشد یا مال محیط دیگری است |
| ERR_AUTH_001 | 401 | بدون توکن |
PUT /api/v1/service-item/{uuid}/treatment-protocol
Replace the whole protocol. Creates it on first call, so this doubles as "turn the switch on".
Everything is validated before anything is written: an invalid step at the end of the list must not wipe the valid steps already stored. Steps and staff are then cleared and rewritten inside one transaction.
Permission: IS_AUTHENTICATED_FULLY, محدود به محیط جاری.
Request Body (application/json)
| Field | Type | Required | توضیح |
|---|---|---|---|
steps |
array | ✅ | ۲ تا ۶۰ گام |
steps[].step_number |
int | ❌ | پیوسته از ۱؛ نبودنش یعنی ترتیب آرایه |
steps[].offset_days |
int | ✅ | فاصله از جلسهٔ قبلی |
staff_uuids |
string[] | ✅ | حداقل یکی، همه از محیط جاری و فعال؛ تکراریها حذف میشوند |
supervisor_doctor_uuid |
string | ❌ | پزشک ناظر؛ باید عضو همین محیط باشد |
{
"staff_uuids": ["53acc523-48a7-4f4a-89b1-745e8e7a69bd"],
"supervisor_doctor_uuid": null,
"steps": [
{ "step_number": 1, "offset_days": 0 },
{ "step_number": 2, "offset_days": 15 },
{ "step_number": 3, "offset_days": 30 },
{ "step_number": 4, "offset_days": 30 }
]
}
Response 200
Same shape as GET.
Errors:
| Code | HTTP | Field | توضیح |
|---|---|---|---|
| ERR_VALIDATION_001 | 422 | steps |
کمتر از ۲ یا بیشتر از ۶۰ گام |
| ERR_VALIDATION_002 | 422 | steps |
فیلد steps نیست یا آرایه نیست |
| ERR_VALIDATION_002 | 422 | offset_days |
offset_days یک گام نیست یا عدد نیست |
| ERR_VALIDATION_001 | 422 | offset_days |
گام اول صفر نیست، یا گام بعدی صفر/منفی است |
| ERR_VALIDATION_001 | 422 | step_number |
شمارهها پیوسته از ۱ نیستند |
| ERR_VALIDATION_002 | 422 | staff_uuids |
فهرست خالی است یا uuid نامعتبر دارد |
| ERR_NOT_FOUND_001 | 404 | staff_uuids |
پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است |
| ERR_NOT_FOUND_001 | 404 | — | پزشک ناظر عضو این محیط نیست |
Real 422 responses:
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"دورهٔ درمان حداقل 2 جلسه دارد؛ کمتر از آن یعنی سرویس تکجلسهای","field":"steps"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"حداقل یک پرسنل مجاز الزامی است","field":"staff_uuids"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"شمارهٔ گامها باید پیوسته از ۱ باشد؛ گام 2 انتظار میرفت","field":"step_number"}]}
Opening a case — what happens on confirm
There is no endpoint that opens a treatment case; it happens as a side effect of confirming an
appointment, in AppointmentConfirmationService::onConfirmed:
نوبت تأیید شد
→ PatientSession ساخته میشود (مالی، مثل همیشه)
→ اگر سرویسِ نوبت پروتکل فعال دارد:
TreatmentWorkflowRegistry::for(clinic.practice_domain.code)->openCase(...)
The booking core never names a specialty. A TreatmentWorkflow is selected by the clinic's practice
domain code through a tagged-service registry, so adding dentistry is a new class rather than a
change in the booking path. LaserTreatmentWorkflow handles beauty;
DefaultTreatmentWorkflow answers for everything else, including a clinic that has chosen no domain
at all — null means "behave as today", never an error.
What opening a case does:
| پروندهٔ باز موجود | برگردانده میشود؛ پروندهٔ دوم برای همان بیمار و همان سرویس ساخته نمیشود |
| نواحی | برگهای دستهٔ سرویس، با نامشان، در همان لحظه کپی میشوند |
| جلسات | همهٔ گامهای پروتکل ساخته میشوند، همه planned |
| نوبت | به اولین جلسهٔ بدون نوبت میچسبد و آن جلسه booked میشود |
| سررسید | جلسهٔ رزروشده ساعت نوبت را میگیرد؛ بقیه null میمانند |
Failure to open a case is logged and swallowed — the appointment is booked and possibly paid for, and losing that is worse than losing the case file, which can be rebuilt.
Session due dates
due_at of session n is finished_at of session n−1 plus that step's offset_days. Only the
next session is recomputed when one finishes; sessions further out keep their earlier estimate,
because a number that is not yet anchored to anything real does not get more accurate by being
recalculated.
A no-show does not burn the session: its status becomes no_show, its appointment link is cleared,
total_sessions is untouched, and the same session comes back to the front of the booking queue.
DELETE /api/v1/service-item/{uuid}/treatment-protocol
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
single-session again. Idempotent: deleting a service that has no protocol still answers 200.
Permission: IS_AUTHENTICATED_FULLY, محدود به محیط جاری.
Response 200
{ "success": true, "data": null }
Treatment cases
GET /api/v1/treatment-cases
پروندههای درمانِ محیط جاری، تازهترین اول.
Permission: IS_AUTHENTICATED_FULLY, محدود به محیط جاری.
| Query | توضیح |
|---|---|
status |
active | completed | abandoned — نبودش یعنی همه |
q |
جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس |
{
"uuid": "…",
"status": "active",
"total_sessions": 4,
"completed_sessions": 1,
"opened_at": 1785660000,
"closed_at": null,
"service": { "uuid": "…", "name": "لیزر توتال" },
"supervisor": { "uuid": "…", "name": "دکتر ناظر" },
"patient": {
"record_uuid": "…",
"name": "محمد رسولی",
"mobile": "09120001111",
"record_number": "۱۲"
},
"areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ]
}
areas[].uuid شناسهٔ همان ردیفِ ناحیه است و category_uuid شناسهٔ دستهٔ کاتالوگ.
ویرایش با دومی کار میکند؛ null یعنی دسته حذف شده و ناحیه فقط در سابقه مانده.
GET /api/v1/treatment-case/{uuid}
همان شکل، بهعلاوهٔ sessions و available_areas — نواحیِ قابل انتخاب برای همین
سرویس، تا فرم ویرایش اندپوینت دومی نخواهد. پروندهٔ محیط دیگر 404 میگیرد.
PATCH /api/v1/treatment-case/{uuid}
ویرایش پروندهٔ درمان. هر فیلد اختیاری است؛ فقط کلیدهای فرستادهشده اعمال میشوند.
| فیلد | توضیح |
|---|---|
status |
active | completed | abandoned. برگرداندن به active پروندهٔ بسته را باز میکند و closed_at را پاک میکند |
supervisor_doctor_uuid |
پزشک ناظر؛ null یعنی بدون ناظر |
area_uuids |
فهرست دستهٔ کاتالوگ، جایگزین کامل. حداقل یکی |
total_sessions |
بین TreatmentProtocol::MIN_STEPS و MAX_STEPS. کمکردن جلسات را از انتها حذف میکند |
مرزِ ثابت: هیچ ویرایشی سابقهٔ انجامشده را بازنویسی نمیکند.
| کد | HTTP | فیلد | شرط |
|---|---|---|---|
| ERR_VALIDATION_001 | 422 | status |
وضعیت نامعتبر |
| ERR_VALIDATION_001 | 422 | area_uuids |
فهرست خالی یا نامعتبر |
| ERR_VALIDATION_001 | 422 | total_sessions |
خارج از بازهٔ مجاز |
| ERR_NOT_FOUND_001 | 404 | supervisor_doctor_uuid / area_uuids |
پزشک یا ناحیه یافت نشد |
| ERR_CONFLICT_001 | 409 | area_uuids |
ناحیه در جلسهای ثبت شده و حذف نمیشود |
| ERR_CONFLICT_001 | 409 | total_sessions |
کمتر از جلساتی که نوبت دارند یا انجام شدهاند |
قواعدش در TreatmentCaseEditor است نه کنترلر.
GET /api/v1/treatment-sessions/unbooked
صفِ «جلسات بدون نوبت» — جلسهای که سررسیدش رسیده و کسی رزروش نکرده.
رزرو جلسهٔ بعد عمداً خودکار نیست: سیستم نمیداند بیمار پنجشنبهها سر کار است و «اولین وقت آزاد» معمولاً بدترین وقت است چون کسی نخواستهاش. پس کار در صفی دیده میشود که منشی از رویش عمل میکند، نه رفتاری که بیصدا اتفاق بیفتد.
| Query | پیشفرض | توضیح |
|---|---|---|
within_days |
7 |
تا چند روز آینده؛ سقف ۹۰ |
جلسهای که از قبل نوبت دارد در این فهرست نمیآید.
GET /api/v1/treatment-session/{uuid}/slot-suggestions
اسلاتهای آزادِ منبع، از سررسید جلسه به بعد. پیشنهاد است، نه رزرو؛ ثبت نوبت از مسیر عادی انجام میشود.
| Query | پیشفرض | توضیح |
|---|---|---|
resource_uuid |
منبعِ جلسهٔ قبلی | ادامهٔ دوره روی همان دستگاه، هم یکنواختتر است هم یک انتخاب کمتر |
days |
14 |
افق جستوجو؛ سقف ۶۰ |
سررسیدِ گذشته یعنی بیمار دیر کرده، پس جستوجو از امروز شروع میشود نه از تاریخی که رد شده. مدتِ نوبت روی همان منبع حل میشود، نه از پیشفرض خام سرویس.
۴۲۲ وقتی نه resource_uuid آمده و نه جلسهای از قبل رزرو شده — فهرست خالی برنمیگردد،
چون «منبعی مشخص نیست» با «وقتی نیست» یکی نیست. ۴۰۴ برای منبع محیط دیگر.
Staff panel — running a session
همهٔ این مسیرها زیر /api/v1/dashboard/staff هستند چون StaffRouteGuardSubscriber کاربرِ
فقط-پرسنل را بیرون از همان پیشوند میبندد؛ باز کردن راهِ تازه با allowlist یعنی مرزِ
دسترسی در دو جا تعریف شود.
Permission: ROLE_STAFF بهعلاوهٔ ردیف فعالِ پرسنل در محیط جاری. نقش بهتنهایی کافی
نیست: توکن تا انقضا معتبر میماند و غیرفعالشدنِ پرسنل باید همان لحظه دسترسی را ببندد.
| Method | Path | کار |
|---|---|---|
| GET | /dashboard/staff/treatment-sessions |
صفِ امروزِ همین پرسنل — پایین را ببینید |
| GET | /dashboard/staff/treatment-session/{uuid} |
جزئیات جلسه + نواحی + devices + forms |
| POST | /dashboard/staff/treatment-session/{uuid}/start |
شروع جلسه |
| POST | /dashboard/staff/treatment-session/{uuid}/finish |
اتمام جلسه |
| POST | /dashboard/staff/session-area/{uuid}/start |
شروع یک ناحیه |
| POST | /dashboard/staff/session-area/{uuid}/complete |
ثبت خواندههای دستگاه |
| POST | /dashboard/staff/session-area/{uuid}/skip |
صرفنظر از ناحیه |
«جلسات امروز من» یک صف است
هر ردیف علاوه بر فیلدهای معمولِ جلسه، اینها را هم دارد:
| فیلد | توضیح |
|---|---|
case_uuid |
پروندهٔ درمانِ همین جلسه |
service_name |
نام سرویس |
patient_name |
نام بیمار، از نوبتِ متصل — null اگر جلسه نوبت ندارد |
resource_name |
دستگاهِ نوبت — null اگر نوبت روی منبع نبوده |
patient_name و resource_name فقط در همین اندپوینت اضافه میشوند، نه در
TreatmentSession::toArray()؛ صفِ اپراتور بدون نام بیمار بیمعنی است ولی هویت بیمار
نباید در هر مصرفکنندهٔ دیگرِ آن متد هم بنشیند.
جلسهٔ امروز از سه راه به یک اپراتور میرسد:
- جلسهای که خودش برداشته —
TreatmentSession.performedByهنگام «شروع جلسه» ست میشود. - نوبتی که منشی از قبل به او داده —
staff_uuidهنگام ثبت نوبت. - کارِ بیصاحبِ امروز، اگر پروتکلِ آن سرویس نامش را برده باشد.
پروتکلی که هیچ پرسنلی برایش تعریف نشده یعنی همه مجازند، نه هیچکس — همان قاعدهٔ
ResourceServiceOffering که نبودِ رکورد را محدودیت حساب نمیکند.
نتیجه همیشه به محیطِ خودِ پرسنل محدود است.
شروع جلسه
رکوردِ هر ناحیهٔ پرونده یک بار ساخته میشود، پس فراخوانی دوباره ناحیهٔ تکراری نمیسازد.
دستگاه از نوبت به ارث میرسد. هر رکورد ناحیه با Appointment.resource ساخته میشود؛ منشی
همان لحظهٔ رزرو انتخابش کرده و پرسیدن دوبارهاش از اپراتور یعنی یک تصمیم را دو بار گرفتن.
اپراتور میتواند per ناحیه عوضش کند — همان کاری که لازم است وقتی بیکینی با الکساندرایت و زیر بغل
با دایود انجام میشود.
پرسنلِ فراخوان بهعنوان انجامدهندهٔ واقعی ثبت میشود — ممکن است با پرسنلِ
برنامهریزیشدهٔ نوبت فرق کند، و سابقهٔ پزشکی باید بگوید چه کسی واقعاً دستگاه را دست گرفت.
وضعیت نوبت به salon میرود. زمان نوبت هرگز بازنویسی نمیشود: slot_start/slot_end
تعهدِ رزرو و ورودیِ محاسبهٔ اشغالاند، و «چقدر طول کشید» در started_at/finished_at جلسه
مینشیند. بازنویسی گذشته یعنی مقایسهٔ پیشبینی با واقعیت برای همیشه از بین میرود.
ثبت یک ناحیه
{
"resource_uuid": "…",
"parameters": { "energy": 18, "pulse": 3, "shots": 212 },
"note": "بدون عارضه"
}
resource_uuid اختیاری است: نبودنش یعنی همان دستگاهِ ارثرسیده، و فرستادنش یعنی اپراتور برای این
ناحیه دستگاه دیگری گذاشته.
درمانِ بیدستگاه مجاز است. بوتاکس تزریق است نه دستگاه؛ اجبارِ دستگاه یعنی کلینیک برای هر
تزریق یک منبع ساختگی بسازد. پس ناحیهای که نه دستگاه دارد و نه مقداری برایش آمده، بسته میشود.
ولی فرستادن parameters بدون دستگاه ⇒ 422 — با چه schemaیی سنجیده شود؟
پاسخِ GET دو کلید کمکی دارد: devices فهرست دستگاههای فعالِ محیط، و forms نگاشت
uuid دستگاه → فیلدهایش. پنل با همین دو، انتخابگر دستگاه و فرم متناظرش را میسازد بدون اینکه
چیزی دربارهٔ لیزر بداند.
parameters با field_schemaِ نوع همان منبع سنجیده میشود — قواعدش در
resource.md. کلید ناشناخته، مقدار خارج از گزینهها و فیلد
الزامیِ نیامده هر سه 422 میگیرند.
ناحیهای که یک بار بسته شده (completed یا skipped) دوباره بسته نمیشود ⇒ 422.
اتمام جلسه
{ "note": "یادداشت کلی جلسه" }
ناحیهٔ ناتمام مانع نیست — اپراتور جلوی بیمار ایستاده و نباید در نرمافزار گیر کند — ولی تعدادشان در پاسخ میآید تا پنل هشدار بدهد:
{ "unsettled_areas": 2 }
وضعیت نوبت completed میشود. اگر گذار مجاز نباشد (منشی وضعیت را دستی عوض کرده) جلسه
بسته میشود و نوبت دستنخورده میماند؛ ماجرا لاگ میشود، خطای ۵۰۰ داده نمیشود.
سپس TreatmentWorkflow حوزهٔ فعالیت سررسید جلسهٔ بعد را از تاریخ واقعیِ همین جلسه
حساب میکند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته میشود.