$categoryUuids * @throws AppException ۴۲۲ روی دستهٔ ناموجود یا دستهٔ محیط دیگر */ public function replaceCategories(ClinicResource $resource, array $categoryUuids): void { $chosen = []; foreach ($categoryUuids as $uuid) { if (!is_string($uuid) || trim($uuid) === '') { continue; } $category = $this->categories->findOneBy(['uuid' => trim($uuid)]); if ($category === null || $category->getEntityType() !== $resource->getEntityType() || $category->getEntityId() !== $resource->getEntityId()) { throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 422, 'category_uuids'); } $chosen[(int) $category->getId()] = $category; } $resource->getCategories()->clear(); foreach ($chosen as $category) { $resource->getCategories()->add($category); } $this->em->flush(); } /** @param array $data */ public function create(DoctorAddress $address, ResourceType $type, array $data, Doctor $supervisor): ClinicResource { $resource = new ClinicResource($address, $type, $this->assertName($data['name'] ?? null)); $resource->setSupervisor($supervisor); $this->applyOptional($resource, $data); $this->em->persist($resource); $this->em->flush(); return $resource; } /** @param array $data */ public function update(ClinicResource $resource, array $data): ClinicResource { if (array_key_exists('name', $data)) { $resource->setName($this->assertName($data['name'])); } $this->applyOptional($resource, $data); $this->em->flush(); return $resource; } public function delete(ClinicResource $resource): void { $this->em->remove($resource); $this->em->flush(); } /** @param array $data */ private function applyOptional(ClinicResource $resource, array $data): void { if (array_key_exists('capacity', $data)) { $this->guard('capacity', fn () => $resource->setCapacity($this->assertInt($data['capacity'], 'capacity'))); } if (array_key_exists('setup_minutes', $data)) { $this->guard('setup_minutes', fn () => $resource->setSetupMinutes($this->assertInt($data['setup_minutes'], 'setup_minutes'))); } if (array_key_exists('cleanup_minutes', $data)) { $this->guard('cleanup_minutes', fn () => $resource->setCleanupMinutes($this->assertInt($data['cleanup_minutes'], 'cleanup_minutes'))); } if (array_key_exists('attributes', $data)) { $resource->setAttributes($this->normalizeAttributes($data['attributes'])); } if (array_key_exists('active', $data)) { $resource->setActive((bool) $data['active']); } } /** * `attributes` آزاد است ولی نه بی‌قید: کلید `[a-z_]{1,40}`، مقدار فقط اسکالر، * حداکثر ۲۰ کلید. * * محدودیت اسکالر عمدی است: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را با * مقایسهٔ ساده روی همین مقادیر می‌سنجند. آرایهٔ تودرتو یعنی مقایسهٔ دلخواه — همان * چیزی که بند ۸ مستند ممنوع کرده. * * @return array */ public function normalizeAttributes(mixed $raw): array { if ($raw === null || $raw === []) { return []; } if (!is_array($raw)) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ویژگی‌ها باید یک شیء باشد', 422, 'attributes'); } if (count($raw) > ClinicResource::MAX_ATTRIBUTES) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, sprintf('حداکثر %d ویژگی مجاز است', ClinicResource::MAX_ATTRIBUTES), 422, 'attributes', ); } $out = []; foreach ($raw as $key => $value) { if (!is_string($key) || preg_match('/^[a-z_]{1,40}$/', $key) !== 1) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, 'نام ویژگی فقط حروف کوچک انگلیسی و زیرخط می‌پذیرد', 422, 'attributes', ); } if ($value !== null && !is_scalar($value)) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, sprintf('مقدار ویژگی «%s» باید یک مقدار ساده باشد', $key), 422, 'attributes', ); } if ($value !== null) { $out[$key] = $value; } } return $out; } /** * قواعد عددی در خودِ entity زندگی می‌کنند تا هیچ مسیری دورشان نزند؛ اینجا فقط * استثنای انگلیسیِ آن‌ها به خطای HTTP فارسی با **فیلد درست** ترجمه می‌شود. */ private function guard(string $field, callable $apply): void { try { $apply(); } catch (\InvalidArgumentException $e) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, $this->persianFor($e->getMessage()), 422, $field); } } private function assertName(mixed $value): string { $name = is_string($value) ? trim($value) : ''; if ($name === '') { throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'نام منبع الزامی است', 422, 'name'); } if (mb_strlen($name) > 150) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام منبع حداکثر ۱۵۰ نویسه است', 422, 'name'); } return $name; } private function assertInt(mixed $value, string $field): int { if (!is_numeric($value)) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf('%s باید عدد باشد', $field), 422, $field); } return (int) $value; } private function persianFor(string $englishMessage): string { return match (true) { str_contains($englishMessage, 'at least 1') => 'ظرفیت منبع حداقل ۱ است', str_contains($englishMessage, 'more than one') => 'منبعی که یک شخص است نمی‌تواند ظرفیت بیش از ۱ داشته باشد', str_contains($englishMessage, 'between 0 and') => 'زمان آماده‌سازی/تمیزکاری باید بین ۰ تا ۴۸۰ دقیقه باشد', default => 'ورودی نامعتبر است', }; } }