Files
clinicpro/src/Resource/Service/ResourceContext.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

125 lines
4.9 KiB
PHP

<?php
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;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
/**
* تبدیل uuidهای درخواست به موجودیت‌های **محیط جاری**.
*
* مالکیت صریح سنجیده می‌شود و به `TenantFilter` تکیه نمی‌کنیم: جداسازی سختِ فیلتر فقط
* روی محیطِ «انتخاب‌شده» اعمال می‌شود، پس کاربری که هنوز محیطی برنگزیده، دادهٔ محیط
* دیگر را می‌دید. همان درسی که در تسک ۰۱ با `RoomCrudTest::testForeignRoomIsNotFound`
* گرفته شد ({@see docs/architecture/tenancy.md}).
*
* همه‌جا ۴۰۴ می‌دهد نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمی‌رود.
*/
final class ResourceContext
{
public function __construct(
private readonly AddressResolver $branches,
private readonly ResourceTypeRepository $types,
private readonly ClinicResourceRepository $resources,
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} */
public function pair(User $user): array
{
return $this->branches->pair($user);
}
public function address(User $user, string $addressUuid): DoctorAddress
{
return $this->branches->resolve($user, $addressUuid);
}
public function type(User $user, string $uuid): ResourceType
{
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), 'منبع یافت نشد');
}
public function skill(User $user, string $uuid): Skill
{
return $this->owned($user, $this->skills->findByUuid($uuid), 'مهارت یافت نشد');
}
public function pool(User $user, string $uuid): ResourcePool
{
return $this->owned($user, $this->pools->findByUuid($uuid), 'استخر منابع یافت نشد');
}
/**
* @template T of object
* @param T|null $entity
* @return T
*/
private function owned(User $user, ?object $entity, string $message): object
{
[$entityType, $entityId] = $this->pair($user);
if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
}
return $entity;
}
}