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:
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user