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}`
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260806132952 extends AbstractMigration
{
public function getDescription(): string
{
return 'Let a resource type declare the fields an operator records for it';
}
public function up(Schema $schema): void
{
// NULL یعنی این نوع منبع فرم ثبت درمان ندارد — حالت اتاق و پرسنل.
$this->addSql('ALTER TABLE resource_types ADD field_schema JSON DEFAULT NULL');
$this->addSql(
'UPDATE resource_types SET field_schema = :schema WHERE code = :code AND field_schema IS NULL',
[
'code' => 'laser',
'schema' => json_encode([
['key' => 'energy', 'label' => 'انرژی', 'type' => 'select', 'required' => true, 'sort_order' => 0, 'options' => [7, 8, 9, 10, 12, 14, 16, 18]],
['key' => 'pulse', 'label' => 'پالس', 'type' => 'select', 'required' => true, 'sort_order' => 1, 'options' => [3, 5, 10, 20]],
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 2],
], JSON_UNESCAPED_UNICODE),
],
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE resource_types DROP field_schema');
}
}
@@ -6,6 +6,7 @@ use App\Auth\Entity\User;
use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourceTypeRepository;
use App\Resource\Service\FieldSchemaValidator;
use App\Resource\Service\ResourceContext;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
@@ -27,6 +28,7 @@ class ResourceTypeController extends BaseController
private readonly ResourceContext $context,
private readonly ResourceTypeRepository $types,
private readonly ClinicResourceRepository $resources,
private readonly FieldSchemaValidator $fieldSchema,
private readonly EntityManagerInterface $em,
) {}
@@ -79,6 +81,13 @@ class ResourceTypeController extends BaseController
}
$type = new ResourceType($entityType, $entityId, $code, $name);
if (array_key_exists('field_schema', $data)) {
$type->setFieldSchema($this->fieldSchema->normalizeSchema(
is_array($data['field_schema']) ? $data['field_schema'] : null,
));
}
$this->em->persist($type);
$this->em->flush();
@@ -108,6 +117,13 @@ class ResourceTypeController extends BaseController
$type->setActive((bool) $data['active']);
}
// کلید نبودن یعنی «دست نزن»؛ `null` یا آرایهٔ خالی یعنی «این نوع منبع فرمی ندارد».
if (array_key_exists('field_schema', $data)) {
$type->setFieldSchema($this->fieldSchema->normalizeSchema(
is_array($data['field_schema']) ? $data['field_schema'] : null,
));
}
$this->em->flush();
return $this->success($type->toArray($this->resources->countForType($type)));
+18
View File
@@ -52,6 +52,18 @@ class ResourceType
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
/**
* فیلدهایی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع ثبت می‌کند.
*
* اینجاست نه روی سرویس، چون خودِ دستگاه تعیین می‌کند چه چیزی خواندنی است: لیزر
* انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. `null` یعنی این نوع منبع فرمی
* ندارد — همان حالت اتاق و پرسنل.
*
* @var list<array{key: string, label: string, type: string, options?: list<scalar>, required?: bool, sort_order?: int}>|null
*/
#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
private ?array $fieldSchema = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -78,10 +90,15 @@ class ResourceType
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function getFieldSchema(): ?array { return $this->fieldSchema; }
public function markSystem(): self { $this->isSystem = true; return $this; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** آرایهٔ خالی همان «فرمی ندارد» است و `null` ذخیره می‌شود تا دو نمایش از یک حالت نماند. */
public function setFieldSchema(?array $v): self { $this->fieldSchema = $v === [] ? null : $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(?int $resourcesCount = null): array
@@ -92,6 +109,7 @@ class ResourceType
'name' => $this->name,
'is_system' => $this->isSystem,
'active' => $this->active,
'field_schema' => $this->fieldSchema,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
@@ -0,0 +1,268 @@
<?php
namespace App\Resource\Service;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* قرارداد فرمِ ثبت درمان: هم تعریف فیلدها را می‌سنجد، هم مقادیری که با آن تعریف ثبت
* می‌شوند.
*
* یک کلاس برای هر دو، چون قاعده یکی است. اگر تعریف و مقدار دو جا اعتبارسنجی شوند،
* اولین باری که یکی‌شان تغییر کند، schemaای ذخیره می‌شود که هیچ مقداری از آن عبور
* نمی‌کند.
*/
final class FieldSchemaValidator
{
public const TYPE_SELECT = 'select';
public const TYPE_NUMBER = 'number';
public const TYPE_TEXT = 'text';
public const TYPES = [self::TYPE_SELECT, self::TYPE_NUMBER, self::TYPE_TEXT];
private const KEY_PATTERN = '/^[a-z][a-z0-9_]{0,39}$/';
private const MAX_FIELDS = 20;
private const MAX_TEXT = 500;
/**
* تعریف فیلدها را نرمال می‌کند؛ `null` یعنی این نوع منبع فرمی ندارد.
*
* @return list<array<string, mixed>>|null
*/
public function normalizeSchema(?array $rows): ?array
{
if ($rows === null || $rows === []) {
return null;
}
if (count($rows) > self::MAX_FIELDS) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('فرم ثبت درمان حداکثر %d فیلد دارد', self::MAX_FIELDS),
422,
'field_schema',
);
}
$schema = [];
$seen = [];
foreach (array_values($rows) as $index => $row) {
if (!is_array($row)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'هر فیلد باید یک شیء باشد', 422, 'field_schema');
}
$key = is_string($row['key'] ?? null) ? trim($row['key']) : '';
if (preg_match(self::KEY_PATTERN, $key) !== 1) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'کلید فیلد باید با حرف کوچک انگلیسی شروع شود و فقط حرف و عدد و زیرخط داشته باشد',
422,
'key',
);
}
if (isset($seen[$key])) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf('کلید «%s» تکراری است', $key), 422, 'key');
}
$seen[$key] = true;
$label = is_string($row['label'] ?? null) ? trim($row['label']) : '';
if ($label === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, sprintf('برچسب فیلد «%s» الزامی است', $key), 422, 'label');
}
$type = is_string($row['type'] ?? null) ? $row['type'] : '';
if (!in_array($type, self::TYPES, true)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('نوع فیلد «%s» باید یکی از %s باشد', $key, implode('، ', self::TYPES)),
422,
'type',
);
}
$field = [
'key' => $key,
'label' => $label,
'type' => $type,
'required' => (bool) ($row['required'] ?? false),
'sort_order' => isset($row['sort_order']) ? (int) $row['sort_order'] : $index,
];
if ($type === self::TYPE_SELECT) {
$options = is_array($row['options'] ?? null) ? array_values($row['options']) : [];
if ($options === []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
sprintf('فیلد انتخابی «%s» باید گزینه داشته باشد', $key),
422,
'options',
);
}
foreach ($options as $option) {
if (!is_scalar($option)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('گزینه‌های فیلد «%s» باید مقدار ساده باشند', $key),
422,
'options',
);
}
}
$field['options'] = $options;
}
$schema[] = $field;
}
usort($schema, static fn (array $a, array $b): int => $a['sort_order'] <=> $b['sort_order']);
return $schema;
}
/**
* مقادیر ثبت‌شده را با تعریف می‌سنجد.
*
* کلید ناشناخته رد می‌شود، نه اینکه بی‌صدا ذخیره شود: مقداری که هیچ فیلدی نشانش
* نمی‌دهد یعنی اپراتور فکر می‌کند چیزی ثبت کرده که هیچ‌وقت دیده نمی‌شود.
*
* @return array<string, scalar>|null
*/
public function validateValues(?array $schema, ?array $values): ?array
{
$values ??= [];
if ($schema === null || $schema === []) {
if ($values !== []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'این نوع منبع فرم ثبت درمان ندارد',
422,
'parameters',
);
}
return null;
}
$byKey = [];
foreach ($schema as $field) {
$byKey[$field['key']] = $field;
}
foreach (array_keys($values) as $key) {
if (!isset($byKey[$key])) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('فیلد «%s» در فرم این دستگاه تعریف نشده است', (string) $key),
422,
'parameters',
);
}
}
$clean = [];
foreach ($byKey as $key => $field) {
$present = array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== '';
if (!$present) {
if ($field['required']) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
sprintf('«%s» الزامی است', $field['label']),
422,
'parameters',
);
}
continue;
}
$clean[$key] = $this->castValue($field, $values[$key]);
}
return $clean === [] ? null : $clean;
}
private function castValue(array $field, mixed $value): string|int|float
{
return match ($field['type']) {
self::TYPE_NUMBER => $this->assertNumeric($field, $value),
self::TYPE_SELECT => $this->assertOption($field, $value),
default => $this->assertText($field, $value),
};
}
private function assertNumeric(array $field, mixed $value): int|float
{
if (!is_numeric($value)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» باید عدد باشد', $field['label']),
422,
'parameters',
);
}
return $value + 0;
}
/** مقایسه با رشته انجام می‌شود تا «۱۸» و ۱۸ یک گزینه حساب شوند، نه دو تا. */
private function assertOption(array $field, mixed $value): string|int|float
{
if (!is_scalar($value)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» مقدار نامعتبر دارد', $field['label']),
422,
'parameters',
);
}
foreach ($field['options'] as $option) {
if ((string) $option === (string) $value) {
return $option;
}
}
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» باید یکی از گزینه‌های تعریف‌شده باشد', $field['label']),
422,
'parameters',
);
}
private function assertText(array $field, mixed $value): string
{
if (!is_scalar($value)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» باید متن باشد', $field['label']),
422,
'parameters',
);
}
$text = trim((string) $value);
if (mb_strlen($text) > self::MAX_TEXT) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» حداکثر %d نویسه دارد', $field['label'], self::MAX_TEXT),
422,
'parameters',
);
}
return $text;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Tests\Resource;
use App\Resource\Service\FieldSchemaValidator;
use App\Shared\Exception\AppException;
use PHPUnit\Framework\TestCase;
/**
* قرارداد فرم ثبت درمان — بدون کرنل، چون هیچ وابستگی‌ای ندارد.
*/
class FieldSchemaTest extends TestCase
{
private FieldSchemaValidator $validator;
protected function setUp(): void
{
$this->validator = new FieldSchemaValidator();
}
private function laserSchema(): array
{
return $this->validator->normalizeSchema([
['key' => 'energy', 'label' => 'انرژی', 'type' => 'select', 'required' => true, 'options' => [7, 8, 18]],
['key' => 'pulse', 'label' => 'پالس', 'type' => 'select', 'required' => true, 'options' => [3, 5]],
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true],
['key' => 'comment', 'label' => 'توضیح', 'type' => 'text'],
]);
}
public function testSchemaIsNormalizedAndSortedByOrder(): void
{
$schema = $this->validator->normalizeSchema([
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'sort_order' => 2],
['key' => 'energy', 'label' => 'انرژی', 'type' => 'select', 'sort_order' => 0, 'options' => [7, 18]],
]);
self::assertSame(['energy', 'shots'], array_column($schema, 'key'));
self::assertFalse($schema[0]['required']);
}
public function testEmptySchemaBecomesNull(): void
{
self::assertNull($this->validator->normalizeSchema([]));
self::assertNull($this->validator->normalizeSchema(null));
}
public function testUnknownFieldTypeIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->normalizeSchema([['key' => 'x', 'label' => 'ایکس', 'type' => 'colour']]);
}
public function testSelectFieldWithoutOptionsIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->normalizeSchema([['key' => 'energy', 'label' => 'انرژی', 'type' => 'select']]);
}
public function testDuplicateKeyIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->normalizeSchema([
['key' => 'energy', 'label' => 'انرژی', 'type' => 'number'],
['key' => 'energy', 'label' => 'دوباره', 'type' => 'number'],
]);
}
public function testInvalidKeyIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->normalizeSchema([['key' => 'Energy Level', 'label' => 'انرژی', 'type' => 'number']]);
}
public function testValidValuesPassAndAreCast(): void
{
$values = $this->validator->validateValues($this->laserSchema(), [
'energy' => '18',
'pulse' => 3,
'shots' => '212',
'comment' => ' خوب پیش رفت ',
]);
self::assertSame(18, $values['energy']);
self::assertSame(3, $values['pulse']);
self::assertSame(212, $values['shots']);
self::assertSame('خوب پیش رفت', $values['comment']);
}
/** مقداری که هیچ فیلدی نشانش نمی‌دهد یعنی اپراتور فکر می‌کند چیزی ثبت کرده که دیده نمی‌شود. */
public function testUnknownKeyIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->validateValues($this->laserSchema(), [
'energy' => 18, 'pulse' => 3, 'shots' => 10, 'wavelength' => 808,
]);
}
public function testValueOutsideOptionsIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->validateValues($this->laserSchema(), [
'energy' => 99, 'pulse' => 3, 'shots' => 10,
]);
}
public function testMissingRequiredValueIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->validateValues($this->laserSchema(), ['energy' => 18, 'pulse' => 3]);
}
public function testOptionalFieldMayBeOmitted(): void
{
$values = $this->validator->validateValues($this->laserSchema(), [
'energy' => 18, 'pulse' => 3, 'shots' => 212,
]);
self::assertArrayNotHasKey('comment', $values);
}
public function testNonNumericValueForNumberFieldIsRejected(): void
{
$this->expectException(AppException::class);
$this->validator->validateValues($this->laserSchema(), [
'energy' => 18, 'pulse' => 3, 'shots' => 'خیلی',
]);
}
/** اتاق و پرسنل فرمی ندارند — فرستادن مقدار برایشان خطاست، نه نادیده گرفته شدن. */
public function testValuesForATypeWithoutASchemaAreRejected(): void
{
$this->expectException(AppException::class);
$this->validator->validateValues(null, ['energy' => 18]);
}
public function testNoSchemaAndNoValuesIsFine(): void
{
self::assertNull($this->validator->validateValues(null, []));
self::assertNull($this->validator->validateValues(null, null));
}
}