From 9af763bfbe7ba13e3abbfa5ee5b58c2d95f3635f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 17:05:48 +0330 Subject: [PATCH] feat(resource): let a resource type declare the fields recorded against it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/resource.md | 55 +++- migrations/Version20260806132952.php | 39 +++ .../Controller/ResourceTypeController.php | 16 ++ src/Resource/Entity/ResourceType.php | 18 ++ src/Resource/Service/FieldSchemaValidator.php | 268 ++++++++++++++++++ tests/Resource/FieldSchemaTest.php | 142 ++++++++++ 6 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20260806132952.php create mode 100644 src/Resource/Service/FieldSchemaValidator.php create mode 100644 tests/Resource/FieldSchemaTest.php diff --git a/docs/api/resource.md b/docs/api/resource.md index 7d938ea9..fb41ef54 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -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}` diff --git a/migrations/Version20260806132952.php b/migrations/Version20260806132952.php new file mode 100644 index 00000000..eea512fa --- /dev/null +++ b/migrations/Version20260806132952.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/src/Resource/Controller/ResourceTypeController.php b/src/Resource/Controller/ResourceTypeController.php index c9180877..a52109d3 100644 --- a/src/Resource/Controller/ResourceTypeController.php +++ b/src/Resource/Controller/ResourceTypeController.php @@ -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))); diff --git a/src/Resource/Entity/ResourceType.php b/src/Resource/Entity/ResourceType.php index cd9b75f6..2777fef7 100644 --- a/src/Resource/Entity/ResourceType.php +++ b/src/Resource/Entity/ResourceType.php @@ -52,6 +52,18 @@ class ResourceType #[ORM\Column(type: 'boolean', options: ['default' => true])] private bool $active = true; + /** + * فیلدهایی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع ثبت می‌کند. + * + * اینجاست نه روی سرویس، چون خودِ دستگاه تعیین می‌کند چه چیزی خواندنی است: لیزر + * انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. `null` یعنی این نوع منبع فرمی + * ندارد — همان حالت اتاق و پرسنل. + * + * @var list, 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, ]; diff --git a/src/Resource/Service/FieldSchemaValidator.php b/src/Resource/Service/FieldSchemaValidator.php new file mode 100644 index 00000000..1378c48b --- /dev/null +++ b/src/Resource/Service/FieldSchemaValidator.php @@ -0,0 +1,268 @@ +>|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|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; + } +} diff --git a/tests/Resource/FieldSchemaTest.php b/tests/Resource/FieldSchemaTest.php new file mode 100644 index 00000000..dd91036f --- /dev/null +++ b/tests/Resource/FieldSchemaTest.php @@ -0,0 +1,142 @@ +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)); + } +}