Files
clinicpro/docs/api/treatment.md
T
hamedandClaude Opus 5 b78f7311cf feat(treatment): per-case operators, shown on the list and searchable
A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:55:01 +03:30

24 KiB
Raw Blame History

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 n1 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 جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و نام پرسنل
from YYYY-MM-DD میلادی — پرونده‌هایی که از ابتدای این روز به بعد باز شده‌اند
to YYYY-MM-DD میلادی — تا انتهای این روز

بازه روی opened_at است نه سررسید جلسه. تایم‌زون تهران (config/bootstrap_tz.php). مقدارِ بدفرم بی‌صدا نادیده گرفته می‌شود، نه خطا — فیلتر است نه ورودی فرم.

{
    "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": "…" } ],
    "assigned_staff": [ { "uuid": "…", "name": "پرسنل۱" } ],
    "performed_by":   [ { "uuid": "…", "name": "پرسنل۱" } ]
}

assigned_staff برنامه است و performed_by سابقه — اولی از خودِ پرونده می‌آید و دومی از TreatmentSession.performedBy. جستجوی q هر دو را می‌گیرد، چون «کارهای این نفر» شامل کارِ انجام‌شده هم هست.

پروندهٔ بدون اختصاص یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس». پرونده‌ای که اپراتور دارد، جلساتش فقط در صفِ همان افراد دیده می‌شود.

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 فهرست دستهٔ کاتالوگ، جایگزین کامل. حداقل یکی
staff_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_NOT_FOUND_001 404 staff_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 صرف‌نظر از ناحیه — note اختیاری
POST /dashboard/staff/session-area/{uuid}/reopen باز کردن دوبارهٔ ناحیهٔ بسته‌شده

«جلسات امروز من» یک صف است

هر ردیف علاوه بر فیلدهای معمولِ جلسه، این‌ها را هم دارد:

فیلد توضیح
case_uuid پروندهٔ درمانِ همین جلسه
service_name نام سرویس
patient_name نام بیمار، از نوبتِ متصل — null اگر جلسه نوبت ندارد
resource_name دستگاهِ نوبت — null اگر نوبت روی منبع نبوده

patient_name و resource_name فقط در همین اندپوینت اضافه می‌شوند، نه در TreatmentSession::toArray()؛ صفِ اپراتور بدون نام بیمار بی‌معنی است ولی هویت بیمار نباید در هر مصرف‌کنندهٔ دیگرِ آن متد هم بنشیند.

جلسهٔ امروز از سه راه به یک اپراتور می‌رسد:

  • جلسه‌ای که خودش برداشته — TreatmentSession.performedBy هنگام «شروع جلسه» ست می‌شود.
  • نوبتی که منشی از قبل به او داده — staff_uuid هنگام ثبت نوبت.
  • کارِ بی‌صاحبِ امروز، اگر پروتکلِ آن سرویس نامش را برده باشد.

پروتکلی که هیچ پرسنلی برایش تعریف نشده یعنی همه مجازند، نه هیچ‌کس — همان قاعدهٔ ResourceServiceOffering که نبودِ رکورد را محدودیت حساب نمی‌کند.

نتیجه همیشه به محیطِ خودِ پرسنل محدود است.

صرف‌نظر و برگرداندن ناحیه

skip یک note اختیاری می‌گیرد. «چرا این ناحیه انجام نشد» بخشی از سابقهٔ درمان است؛ بدونش جلسهٔ بعد فقط یک خلأ بی‌توضیح می‌بیند.

reopen ناحیهٔ بسته‌شده — چه completed چه skipped — را برمی‌گرداند، برای اشتباهی که حین کار معلوم می‌شود. وضعیت به in_progress برمی‌گردد (یا pending اگر هرگز شروع نشده بود) و finished_at پاک می‌شود. parameters و note می‌مانند تا اپراتور ببیند چه ثبت شده بود و رویش بنویسد.

کد HTTP شرط
ERR_VALIDATION_001 422 ناحیه باز است و چیزی برای برگرداندن ندارد
ERR_CONFLICT_001 409 جلسه بسته شده؛ رکورد دیگر سابقه است نه فرم

شروع جلسه

رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد.

دستگاه از نوبت به ارث می‌رسد. هر رکورد ناحیه با 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 حوزهٔ فعالیت سررسید جلسهٔ بعد را از تاریخ واقعیِ همین جلسه حساب می‌کند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته می‌شود.