Files
clinicpro/src/Resource/Entity/ClinicResource.php
T
hamedandClaude Opus 5 964c09cc00 feat(resource): resource types, resources, skills and pools
The document's first golden rule is "the calendar belongs to the resource, not to
the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff
is a label on services and appointments with no calendar, capacity or skills. This
adds the layer underneath: anything that can be busy — doctor, operator, assistant,
device, room, bed, chair.

Two corrections to the planned schema:

- `address_id` → doctor_addresses, not `branch_id` → a new branches table. The
  branch already exists and is the address (task 01).
- UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per
  (doctor, clinic) but every session inside it carries its own location_id, so one
  doctor already works at several addresses within one environment. Keying on the
  doctor alone would have made that unrepresentable — and task 03 gives each
  resource its own calendar, which is exactly per-location.

Design points worth keeping:

- Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those
  three have live consumers (appointments.doctor_id, service_item_staff, the public
  site) and subclassing would mean migrating all of them at once. At most one bridge
  column is non-null, enforced in the entity because MariaDB will not reliably
  enforce a multi-column CHECK.
- Capacity is concurrency: a three-bed injection room is one resource with capacity
  3, not three resources, so occupancy in task 06 stays a COUNT against a limit
  instead of a merge of three calendars. A person resource is refused capacity > 1.
- Skills are a table, not rules. With 50 operators and 200 services, expressing
  "who may operate what" as policy would mean 10,000 rules.
- findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both;
  a plain IN would have matched a resource holding only one.
- setup/cleanup minutes occupy the resource without being part of the patient's
  appointment, and are per-resource — distinct from the existing per-doctor
  WeeklySchedule.meta.buffer_minutes, which stays untouched.

Two real bugs found by running the backfill against real data rather than fixtures:

ResourceLinker::systemType() persisted a type without flushing, so the next lookup
missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff'
for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work.

The command looped over every WeeklySchedule once per environment, which is
quadratic and never finished on real data. Doctors are now a single pass keyed by
the schedule's own environment. It also flushes per environment and accepts
--pair=clinic:12, so one bad row cannot close the EntityManager and abort a
fleet-wide run, and operators can re-run for a single clinic.

Staff are the one case that cannot be derived: nothing records which branch they
work at. Rather than guessing the first one and seating them in the wrong building,
multi-branch environments are skipped and reported.

88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on
src/Resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:34:36 +03:30

248 lines
11 KiB
PHP

<?php
namespace App\Resource\Entity;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Branch\Entity\Room;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
* قانون طلایی مستند: «تقویم مال منبع است، نه مال پزشک» — تقویمش در تسک ۰۳ می‌آید.
*
* نام کلاس عمداً `Resource` نیست: در این کدبیس با مفهوم «منبع API» قاطی می‌شود و
* جستجوی کد را پر نویز می‌کند.
*
* ## پل، نه ادغام
*
* `Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
* (`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاس
* یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش حداکثر **یکی** از سه ستون پل پر است؛
* منبعِ بدون پل یعنی دستگاه یا تجهیزات.
*/
#[ORM\Entity(repositoryClass: ClinicResourceRepository::class)]
#[ORM\Table(name: 'clinic_resources')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_resources_tenant')]
#[ORM\Index(columns: ['address_id', 'resource_type_id', 'active'], name: 'idx_resources_address_type')]
#[ORM\UniqueConstraint(name: 'uniq_resource_doctor_address', columns: ['doctor_id', 'address_id'])]
#[ORM\UniqueConstraint(name: 'uniq_resource_staff_address', columns: ['staff_id', 'address_id'])]
#[ORM\UniqueConstraint(name: 'uniq_resource_room', columns: ['room_id'])]
class ClinicResource
{
use TenantOwnedTrait;
public const MAX_ATTRIBUTES = 20;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/**
* منابع همیشه مال یک شعبه‌اند، و «شعبه» همان آدرس محل نوبت‌دهی است
* ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
*/
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private DoctorAddress $address;
#[ORM\ManyToOne(targetEntity: ResourceType::class)]
#[ORM\JoinColumn(name: 'resource_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private ResourceType $type;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
/** ظرفیت هم‌زمان: اتاق تزریق سه‌تخته یک ردیف با ظرفیت ۳ است، نه سه ردیف. */
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $capacity = 1;
/** آماده‌سازی پیش از بیمار — جزو نوبت بیمار نیست، ولی منبع را اشغال می‌کند. */
#[ORM\Column(name: 'setup_minutes', type: 'smallint', options: ['default' => 0])]
private int $setupMinutes = 0;
#[ORM\Column(name: 'cleanup_minutes', type: 'smallint', options: ['default' => 0])]
private int $cleanupMinutes = 0;
/** JSON آزاد ولی فقط اسکالر — {@see \App\Resource\Service\ResourceService::normalizeAttributes()} */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $attributes = null;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Doctor $doctor = null;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ClinicStaff $staff = null;
#[ORM\ManyToOne(targetEntity: Room::class)]
#[ORM\JoinColumn(name: 'room_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Room $room = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
/** @var Collection<int, ResourceSkill> */
#[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $skills;
public function __construct(DoctorAddress $address, ResourceType $type, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->address = $address;
$this->type = $type;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->skills = new ArrayCollection();
// جفت از آدرس مشتق می‌شود، نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
// نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند منبع را به محیط دیگری بچسباند.
$this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getAddress(): DoctorAddress { return $this->address; }
public function getType(): ResourceType { return $this->type; }
public function getName(): string { return $this->name; }
public function getCapacity(): int { return $this->capacity; }
public function getSetupMinutes(): int { return $this->setupMinutes; }
public function getCleanupMinutes(): int { return $this->cleanupMinutes; }
public function getAttributes(): array { return $this->attributes ?? []; }
public function getDoctor(): ?Doctor { return $this->doctor; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getRoom(): ?Room { return $this->room; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
/** @return Collection<int, ResourceSkill> */
public function getSkills(): Collection { return $this->skills; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setSetupMinutes(int $v): self { $this->setupMinutes = $this->assertMinutes($v, 'setup_minutes'); $this->touch(); return $this; }
public function setCleanupMinutes(int $v): self { $this->cleanupMinutes = $this->assertMinutes($v, 'cleanup_minutes'); $this->touch(); return $this; }
public function setAttributes(array $v): self { $this->attributes = $v === [] ? null : $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function setType(ResourceType $v): self { $this->type = $v; $this->touch(); return $this; }
/**
* @throws \InvalidArgumentException ظرفیت کمتر از ۱، یا بزرگ‌تر از ۱ روی منبعی که
* یک شخص است — پزشک و اپراتور هم‌زمان دو بیمار ندارند.
*/
public function setCapacity(int $v): self
{
if ($v < 1) {
throw new \InvalidArgumentException('Resource capacity must be at least 1.');
}
if ($v > 1 && $this->isPerson()) {
throw new \InvalidArgumentException('A person resource cannot serve more than one patient at a time.');
}
$this->capacity = $v;
$this->touch();
return $this;
}
/** منبعی که یک انسان است: ظرفیتش همیشه ۱ می‌ماند. */
public function isPerson(): bool
{
return $this->doctor !== null
|| $this->staff !== null
|| in_array($this->type->getCode(), [ResourceType::CODE_DOCTOR, ResourceType::CODE_STAFF], true);
}
/**
* پل به موجودیت اصلی. حداکثر یکی مجاز است — MariaDB قید چندستونی `CHECK` را
* قابل اتکا اجرا نمی‌کند، پس اجبارش اینجاست.
*
* @throws \InvalidArgumentException روی پل دوم
*/
public function linkTo(Doctor|ClinicStaff|Room $subject): self
{
if ($this->subject() !== null) {
throw new \InvalidArgumentException('A resource can bridge to at most one subject.');
}
match (true) {
$subject instanceof Doctor => $this->doctor = $subject,
$subject instanceof ClinicStaff => $this->staff = $subject,
$subject instanceof Room => $this->room = $subject,
};
$this->touch();
return $this;
}
/** موجودیت اصلی پشت این منبع؛ `null` یعنی دستگاه/تجهیزات. */
public function subject(): Doctor|ClinicStaff|Room|null
{
return $this->doctor ?? $this->staff ?? $this->room;
}
private function assertMinutes(int $v, string $field): int
{
if ($v < 0 || $v > 480) {
throw new \InvalidArgumentException(sprintf('%s must be between 0 and 480.', $field));
}
return $v;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
$subject = $this->subject();
return [
'uuid' => $this->uuid,
'name' => $this->name,
'address_uuid' => $this->address->getUuid(),
'address_name' => $this->address->getName(),
'type_uuid' => $this->type->getUuid(),
'type_code' => $this->type->getCode(),
'type_name' => $this->type->getName(),
'capacity' => $this->capacity,
'setup_minutes' => $this->setupMinutes,
'cleanup_minutes' => $this->cleanupMinutes,
'attributes' => (object) $this->getAttributes(),
'subject_kind' => match (true) {
$this->doctor !== null => 'doctor',
$this->staff !== null => 'staff',
$this->room !== null => 'room',
default => null,
},
'subject_uuid' => $subject?->getUuid(),
'skills' => array_map(
static fn (ResourceSkill $rs): array => $rs->toArray(),
$this->skills->toArray(),
),
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}