The page an operator opens to see the day's work did not say who any of it was for. TreatmentSession::toArray() carries no patient, so the list showed a service name, a time, and a repeated 40-char button — the same three lines for every row, with the finished work leading. The endpoint now sends patient_name and resource_name. They are added in the controller next to case_uuid/service_name rather than in toArray(), so patient identity does not leak into every other consumer of that method. The list is now a queue: unfinished work first, settled work (done, cancelled, no-show) below it, each group counted. Every row leads with its time, names the patient, and carries the service, device, session number and area progress on one meta line. The whole row is the link, so the repeated button is gone. Also fixes three things the redesign checklist calls out: Latin digits in the session and area counts (formatNumber), a date repeated on every row of a page whose title is "today", and a missing error state — a failed request rendered as "no sessions today", which reads as an empty day rather than a broken one. Adds the test file the page never had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
19 KiB
Markdown
373 lines
19 KiB
Markdown
# 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` حوزهٔ فعالیت سررسید جلسهٔ بعد را از **تاریخ واقعیِ همین جلسه**
|
||
حساب میکند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته میشود.
|