Files
clinicpro/src/Resource/Service/ResourceService.php
T
hamedandClaude Opus 5 ab4974d174 feat(resource): every resource is supervised by a doctor
Supervision now lives on the resource itself instead of being asked for again at
booking time, so one relation answers it everywhere.

The column is deliberately separate from the existing doctor_id bridge. That
bridge means "this resource IS this doctor" and isPerson() uses it to pin
capacity at 1; a supervised three-seat device must not become a person resource.
The FK is SET NULL rather than CASCADE because deleting a doctor should not take
the clinic's laser with it.

Required on create and non-clearable on update, enforced in the API where it can
give a Persian message. Ownership is checked through Clinic::hasDoctor so a
secretary cannot put their device under a doctor of another clinic; that returns
404, not 403, keeping foreign data invisible.

The 13 existing resources are backfilled deterministically: a practice resource
gets its own doctor, a clinic resource gets that clinic's first doctor. Both are
editable from the resource form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:15:42 +03:30

220 lines
8.1 KiB
PHP

<?php
namespace App\Resource\Service;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
final class ResourceService
{
/** کلیدهای شناخته‌شدهٔ `attributes` — قرارداد، نه اجبار. */
public const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly \App\ClinicService\Repository\CatalogCategoryRepository $categories,
) {}
/**
* دسته‌های این منبع — جایگزینی کامل.
*
* دسته فقط **انتخاب** می‌شود؛ ساختش کار صفحهٔ «تنظیمات ← دسته‌بندی‌ها» است. دستهٔ
* محیط دیگر رد می‌شود چون uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد.
*
* @param list<mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, scalar>
*/
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 => 'ورودی نامعتبر است',
};
}
}