Files
clinicpro/docs/api/treatment.md
T
hamedandClaude Opus 5 a63de2a52c fix(treatment): let the operator actually record an area
Starting a session created area records with no device, and the panel only ever
read the device it never set — so every "اتمام این ناحیه" came back 422 with
"دستگاه این ناحیه مشخص نیست". The backend tests passed because they sent
resource_uuid explicitly; from the UI the flow was unusable end to end.

The device now inherits from the appointment's resource, which the secretary
already chose at booking; asking the operator again is taking one decision
twice. The session screen offers a picker per area on top of that, because one
session really does run bikini on an alexandrite and underarms on a diode.

Treating without a device is allowed: botox is an injection, and requiring a
device would make clinics invent a fake resource per injection. Sending readings
with no device is still rejected — there would be no schema to validate against.

A protocol whose service has no ResourceServiceOffering rows now says so in the
tab where the manager is standing. It does not block booking: "no offering means
any resource" is a deliberate, tested rule. But silence meant the gap surfaced
only when the operator was already in front of a patient.

Also adds the live timer the spec asked for, and wires slot-suggestions into the
unbooked queue — the endpoint existed and tested green but no screen called it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 11:08:48 +03:30

17 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 — نبودش یعنی همه
{
    "uuid": "…",
    "status": "active",
    "total_sessions": 4,
    "completed_sessions": 1,
    "opened_at": 1785660000,
    "closed_at": null,
    "service": { "uuid": "…", "name": "لیزر توتال" },
    "supervisor": { "uuid": "…", "name": "دکتر ناظر" },
    "areas": [ { "uuid": "…", "name": "بیکینی" } ]
}

GET /api/v1/treatment-case/{uuid}

همان شکل، به‌علاوهٔ sessions. پروندهٔ محیط دیگر 404 می‌گیرد.


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 صرف‌نظر از ناحیه

شروع جلسه

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

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