# 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`
```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 *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`
```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}` | جزئیات جلسه + نواحی + `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` جلسه
می‌نشیند. بازنویسی گذشته یعنی مقایسهٔ پیش‌بینی با واقعیت برای همیشه از بین می‌رود.

### ثبت یک ناحیه

```json
{
  "resource_uuid": "…",
  "parameters": { "energy": 18, "pulse": 3, "shots": 212 },
  "note": "بدون عارضه"
}
```

`resource_uuid` اختیاری است: نبودنش یعنی همان دستگاهِ ارث‌رسیده، و فرستادنش یعنی اپراتور برای این
ناحیه دستگاه دیگری گذاشته.

**درمانِ بی‌دستگاه مجاز است.** بوتاکس تزریق است نه دستگاه؛ اجبارِ دستگاه یعنی کلینیک برای هر
تزریق یک منبع ساختگی بسازد. پس ناحیه‌ای که نه دستگاه دارد و نه مقداری برایش آمده، بسته می‌شود.
ولی فرستادن `parameters` بدون دستگاه ⇒ `422` — با چه schemaیی سنجیده شود؟

پاسخِ `GET` دو کلید کمکی دارد: `devices` فهرست دستگاه‌های فعالِ محیط، و `forms` نگاشت
`uuid دستگاه → فیلدهایش`. پنل با همین دو، انتخابگر دستگاه و فرم متناظرش را می‌سازد بدون اینکه
چیزی دربارهٔ لیزر بداند.

`parameters` با `field_schema`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در
[resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد
الزامیِ نیامده هر سه `422` می‌گیرند.

ناحیه‌ای که یک بار بسته شده (`completed` یا `skipped`) دوباره بسته نمی‌شود ⇒ `422`.

### اتمام جلسه

```json
{ "note": "یادداشت کلی جلسه" }
```

**ناحیهٔ ناتمام مانع نیست** — اپراتور جلوی بیمار ایستاده و نباید در نرم‌افزار گیر کند — ولی
تعدادشان در پاسخ می‌آید تا پنل هشدار بدهد:

```json
{ "unsettled_areas": 2 }
```

وضعیت نوبت `completed` می‌شود. اگر گذار مجاز نباشد (منشی وضعیت را دستی عوض کرده) جلسه
بسته می‌شود و نوبت دست‌نخورده می‌ماند؛ ماجرا لاگ می‌شود، خطای ۵۰۰ داده نمی‌شود.

سپس `TreatmentWorkflow` حوزهٔ فعالیت سررسید جلسهٔ بعد را از **تاریخ واقعیِ همین جلسه**
حساب می‌کند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته می‌شود.
