Files
clinicpro/docs/api/treatment.md
T
hamedandClaude Opus 5 77eeefd5b4 feat(treatment): run a session from the staff panel, area by area
The operator opens the session, treats each body area on its own device and
records what that device was set to. Readings are validated against the resource
type's field schema, so a laser form and an RF form each enforce their own rules
without this code naming either.

Finishing is allowed with areas still open — the operator is standing in front of
a patient and must not be trapped by the software — but the count comes back so
the panel can warn. Session state mirrors onto the appointment (salon, then
completed) while its slot times are never rewritten: those are the reservation's
promise and the input to occupancy, whereas how long it actually took belongs to
the session. Overwriting them would destroy the comparison between the two.

Who performed it is recorded on the session rather than inferred from the
appointment's planned staff: when a colleague covers a sick operator, the medical
record must say who actually held the device.

Endpoints live under /api/v1/dashboard/staff because StaffRouteGuardSubscriber
closes everything else to staff-only users. Opening a second door through its
allowlist would put the access boundary in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:17:27 +03:30

320 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
### Response `200`
```json
{
"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 | ❌ | پزشک ناظر؛ باید عضو همین محیط باشد |
```json
{
"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:
```json
{"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`
```json
{ "success": true, "data": null }
```
---
# Treatment cases
## GET `/api/v1/treatment-cases`
پرونده‌های درمانِ محیط جاری، تازه‌ترین اول.
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
| Query | توضیح |
|---|---|
| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه |
```json
{
"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}` | جزئیات جلسه + نواحی + `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` | صرف‌نظر از ناحیه |
### شروع جلسه
رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد.
پرسنلِ فراخوان به‌عنوان **انجام‌دهندهٔ واقعی** ثبت می‌شود — ممکن است با پرسنلِ
برنامه‌ریزی‌شدهٔ نوبت فرق کند، و سابقهٔ پزشکی باید بگوید چه کسی واقعاً دستگاه را دست گرفت.
وضعیت نوبت به `salon` می‌رود. **زمان نوبت هرگز بازنویسی نمی‌شود**: `slot_start`/`slot_end`
تعهدِ رزرو و ورودیِ محاسبهٔ اشغال‌اند، و «چقدر طول کشید» در `started_at`/`finished_at` جلسه
می‌نشیند. بازنویسی گذشته یعنی مقایسهٔ پیش‌بینی با واقعیت برای همیشه از بین می‌رود.
### ثبت یک ناحیه
```json
{
"resource_uuid": "…",
"parameters": { "energy": 18, "pulse": 3, "shots": 212 },
"note": "بدون عارضه"
}
```
`parameters` با `field_schema`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در
[resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد
الزامیِ نیامده هر سه `422` می‌گیرند.
ناحیه‌ای که یک بار بسته شده (`completed` یا `skipped`) دوباره بسته نمی‌شود ⇒ `422`.
### اتمام جلسه
```json
{ "note": "یادداشت کلی جلسه" }
```
**ناحیهٔ ناتمام مانع نیست** — اپراتور جلوی بیمار ایستاده و نباید در نرم‌افزار گیر کند — ولی
تعدادشان در پاسخ می‌آید تا پنل هشدار بدهد:
```json
{ "unsettled_areas": 2 }
```
وضعیت نوبت `completed` می‌شود. اگر گذار مجاز نباشد (منشی وضعیت را دستی عوض کرده) جلسه
بسته می‌شود و نوبت دست‌نخورده می‌ماند؛ ماجرا لاگ می‌شود، خطای ۵۰۰ داده نمی‌شود.
سپس `TreatmentWorkflow` حوزهٔ فعالیت سررسید جلسهٔ بعد را از **تاریخ واقعیِ همین جلسه**
حساب می‌کند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته می‌شود.