feat(resource): let a resource type declare the fields recorded against it

What an operator writes down after treating an area is decided by the device,
not by the service: a laser has energy, pulse and shot count, an RF unit has
something else. So the field list lives on the resource type, and adding a new
kind of device becomes a settings change rather than a migration.

One validator covers both directions — the schema when a manager saves it and
the values when an operator submits them. Splitting them would let a schema be
stored that no value can ever satisfy.

A value whose key is not in the schema is rejected rather than stored: silently
keeping it means the operator believes they recorded something that will never
be shown back to them. Option matching compares as strings so "18" and 18 are
one option, not two.

The migration seeds the laser type's three fields onto existing rows that have
none, so clinics already running laser devices do not start from an empty form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 17:05:48 +03:30
co-authored by Claude Opus 5
parent 6847a473d4
commit 9af763bfbe
6 changed files with 535 additions and 3 deletions
+52 -3
View File
@@ -94,6 +94,7 @@
|---|---|---|---|
| `code` | string | ✅ | `[a-z0-9_]{1,40}` · یکتا **per محیط** (همان کد در محیط دیگر مجاز است) |
| `name` | string | ✅ | نام نمایشی فارسی |
| `field_schema` | array\|null | ❌ | فیلدهای فرم ثبت درمان — [پایین‌تر](#فرم-ثبت-درمان) |
**۲۰۱** (خروجی واقعی):
@@ -117,9 +118,57 @@
### `PATCH /api/v1/resource-type/{uuid}`
فقط `name` و `active`. **`code` تغییر نمی‌کند** حتی روی نوع غیرسیستمی: منابع موجود و
پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. فرستادنش خطا
نمی‌دهد، نادیده گرفته می‌شود.
فقط `name` و `active` و `field_schema`. **`code` تغییر نمی‌کند** حتی روی نوع غیرسیستمی:
منابع موجود و پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند.
فرستادنش خطا نمی‌دهد، نادیده گرفته می‌شود.
نبودنِ کلید `field_schema` یعنی «دست نزن»؛ `null` یا آرایهٔ خالی یعنی «این نوع منبع فرمی
ندارد» و هر دو به `null` ذخیره می‌شوند.
### فرم ثبت درمان
هر نوع منبع می‌گوید اپراتور بعد از درمانِ هر ناحیه با آن چه چیزی ثبت کند. تعریف اینجاست
نه روی سرویس، چون خودِ دستگاه تعیین می‌کند چه چیزی خواندنی است: لیزر انرژی و پالس و شات
دارد، دستگاه RF چیز دیگری. افزودن دستگاه تازه تنظیمات است، نه migration.
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
| `key` | string | ✅ | `^[a-z][a-z0-9_]{0,39}$` · یکتا در همان schema |
| `label` | string | ✅ | برچسب فارسی که به اپراتور نشان داده می‌شود |
| `type` | string | ✅ | `select` یا `number` یا `text` — همین سه |
| `options` | array | فقط برای `select` | مقادیر ساده؛ فهرست خالی رد می‌شود |
| `required` | bool | ❌ | پیش‌فرض `false` |
| `sort_order` | int | ❌ | پیش‌فرض ترتیب آرایه؛ خروجی بر همین اساس مرتب می‌شود |
حداکثر ۲۰ فیلد. مقدار `text` حداکثر ۵۰۰ نویسه.
خروجی واقعی `PATCH` روی یک نوعِ لیزر:
```json
{
"key": "energy",
"label": "انرژی",
"type": "select",
"required": true,
"sort_order": 0,
"options": [7, 8, 9, 10, 12, 14, 16, 18]
}
```
**۴۲۲ های واقعی:**
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"نوع فیلد «x» باید یکی از select، number، text باشد","field":"type"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"فیلد انتخابی «energy» باید گزینه داشته باشد","field":"options"}]}
```
**مقادیر** هم با همین تعریف سنجیده می‌شوند، وقتی اپراتور ناحیه‌ای را تمام می‌کند:
- کلیدی که در schema نیست **رد می‌شود**، نه اینکه بی‌صدا ذخیره شود — وگرنه اپراتور فکر
می‌کند چیزی ثبت کرده که هیچ‌وقت دیده نمی‌شود.
- مقدار خارج از `options` رد می‌شود؛ مقایسه رشته‌ای است تا `"18"` و `18` یک گزینه باشند.
- فیلد `required` که نیامده باشد ۴۲۲ می‌گیرد؛ فیلد اختیاری از خروجی حذف می‌شود.
- برای نوع منبعی که `field_schema` ندارد، فرستادن هر مقداری ۴۲۲ است.
### `DELETE /api/v1/resource-type/{uuid}`