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)); + } +}