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>
This commit is contained in:
hamed
2026-08-03 12:15:42 +03:30
co-authored by Claude Opus 5
parent b03ae95bf8
commit ab4974d174
10 changed files with 318 additions and 10 deletions
+19 -3
View File
@@ -81,10 +81,17 @@ class ResourceController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid');
}
$address = $this->context->address($user, $data['address_uuid']);
$type = $this->context->type($user, $data['type_uuid']);
// هر منبع زیر نظر یک پزشک است — صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک می‌چیند،
// پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد.
if (!is_string($data['supervisor_doctor_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid');
}
return $this->success($this->service->create($address, $type, $data)->toArray(), 201);
$address = $this->context->address($user, $data['address_uuid']);
$type = $this->context->type($user, $data['type_uuid']);
$supervisor = $this->context->supervisor($user, $data['supervisor_doctor_uuid']);
return $this->success($this->service->create($address, $type, $data, $supervisor)->toArray(), 201);
}
#[Route('/api/v1/resource/{uuid}', name: 'resource_show', methods: ['GET'])]
@@ -118,6 +125,15 @@ class ResourceController extends BaseController
$resource->setType($this->context->type($user, $data['type_uuid']));
}
// ناظر عوض می‌شود ولی برداشته نمی‌شود: فرستادن رشتهٔ خالی یعنی منبعِ بی‌ناظر.
if (array_key_exists('supervisor_doctor_uuid', $data)) {
if (!is_string($data['supervisor_doctor_uuid']) || $data['supervisor_doctor_uuid'] === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid');
}
$resource->setSupervisor($this->context->supervisor($user, $data['supervisor_doctor_uuid']));
}
return $this->success($this->service->update($resource, $data)->toArray());
}
+27
View File
@@ -84,6 +84,20 @@ class ClinicResource
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ClinicStaff $staff = null;
/**
* پزشکِ ناظرِ منبع — کسی که این دستگاه/اتاق زیر نظر او کار می‌کند.
*
* عمداً از `$doctor` جداست: آن **پل** است («این منبع خودِ همان پزشک است») و
* `isPerson()` بر پایه‌اش ظرفیت را به ۱ قفل می‌کند. ناظرِ یک دستگاهِ سه‌ظرفیتی
* نباید آن را به منبعِ انسانی تبدیل کند.
*
* `SET NULL` نه `CASCADE`: حذف پزشک نباید دستگاه کلینیک را پاک کند. ستون در
* دیتابیس تهی‌پذیر است تا حذف پزشک ردیف را نشکند، ولی API ناظر را الزامی می‌گیرد.
*/
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'supervisor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Doctor $supervisor = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
@@ -183,6 +197,16 @@ class ClinicResource
return $this;
}
public function getSupervisor(): ?Doctor { return $this->supervisor; }
public function setSupervisor(?Doctor $doctor): self
{
$this->supervisor = $doctor;
$this->touch();
return $this;
}
/** منبعی که یک انسان است: ظرفیتش همیشه ۱ می‌ماند. */
public function isPerson(): bool
{
@@ -246,6 +270,9 @@ class ClinicResource
'setup_minutes' => $this->setupMinutes,
'cleanup_minutes' => $this->cleanupMinutes,
'attributes' => (object) $this->getAttributes(),
'supervisor' => $this->supervisor === null
? null
: ['uuid' => $this->supervisor->getUuid(), 'name' => $this->supervisor->getName()],
'subject_kind' => match (true) {
$this->doctor !== null => 'doctor',
$this->staff !== null => 'staff',
+37
View File
@@ -4,13 +4,16 @@ namespace App\Resource\Service;
use App\Auth\Entity\User;
use App\Doctor\Service\AddressResolver;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourcePool;
use App\Resource\Entity\ResourceType;
use App\Resource\Entity\Skill;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourcePoolRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Resource\Repository\ResourceTypeRepository;
use App\Resource\Repository\SkillRepository;
use App\Shared\Constant\ErrorCodes;
@@ -36,6 +39,8 @@ final class ResourceContext
private readonly SkillRepository $skills,
private readonly ResourcePoolRepository $pools,
private readonly TenantOwnershipChecker $ownership,
private readonly DoctorRepository $doctors,
private readonly ClinicRepository $clinics,
) {}
/** @return array{0: string, 1: int} */
@@ -54,6 +59,38 @@ final class ResourceContext
return $this->owned($user, $this->types->findByUuid($uuid), 'نوع منبع یافت نشد');
}
/**
* پزشکِ ناظرِ منبع.
*
* `Doctor` تحت `TenantOwnedTrait` نیست (پزشک می‌تواند هم‌زمان عضو چند کلینیک باشد)،
* پس مالکیت با عضویت سنجیده می‌شود: در محیط کلینیک باید پزشکِ همان کلینیک باشد، و در
* مطب شخصی باید خودِ همان پزشک. بدون این، منشیِ یک کلینیک می‌توانست دستگاهش را زیر
* نظر پزشک کلینیک دیگری ببرد.
*/
public function supervisor(User $user, string $uuid): Doctor
{
$doctor = $this->doctors->findByUuid($uuid);
if ($doctor === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک ناظر یافت نشد', 404);
}
[$entityType, $entityId] = $this->pair($user);
// رابطه از سمت کلینیک تعریف شده (`Clinic::$doctors`)، پس عضویت را خودِ کلینیک
// جواب می‌دهد — `Clinic::hasDoctor()`؛ کوئری تازه‌ای لازم نیست.
$clinic = $entityType === 'clinic' ? $this->clinics->find($entityId) : null;
$belongs = $entityType === 'clinic'
? ($clinic !== null && $clinic->hasDoctor($doctor))
: (int) $doctor->getId() === $entityId;
if (!$belongs) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک ناظر یافت نشد', 404);
}
return $doctor;
}
public function resource(User $user, string $uuid): ClinicResource
{
return $this->owned($user, $this->resources->findByUuid($uuid), 'منبع یافت نشد');
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Resource\Service;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
@@ -57,9 +58,10 @@ final class ResourceService
}
/** @param array<string, mixed> $data */
public function create(DoctorAddress $address, ResourceType $type, array $data): ClinicResource
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);