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:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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), 'منبع یافت نشد');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user