Files
clinicpro/src/Branch/Entity/Room.php
T
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging
to it. That plan was wrong: the branch already exists and is called
`DoctorAddress`. It carries name, address, telephone, coordinates, city/province
FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system
already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id`
points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a
booking location, and nine CRUD endpoints plus four admin pages manage them.
A parallel table would mean two sources of truth for one physical place and a
branch that `location_id` never references.

So no `branches` table and no duplicate branch CRUD. Only the three genuinely
missing pieces:

- `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so
  existing rows need no backfill and no current behaviour changes. `active` is
  stored only — applying it to slot calculation is task 03, since touching
  `SlotCalculatorService` is off limits in this phase.
- `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight
  rather than "09:00" strings so range intersection stays arithmetic. PUT
  replaces all seven days; validation of the whole week runs before any DELETE,
  so an invalid sixth day cannot wipe the five valid ones and then answer 422.
- `rooms`, with `capacity` as concurrency (a three-bed injection room is one
  resource with capacity 3, not three resources) and a deletion-guard iterator
  so tasks 02 and 07 can add reasons without editing RoomService.

`BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`;
TenantSchemaCoverageTest rejected it correctly, because that root is itself
declared global. It now carries a real tenant pair instead, derived in the
constructor from the address's `type` — a total mapping, and the address is only
ever listed in its own context, so nothing is hidden wrongly.

RoomController checks ownership explicitly rather than trusting TenantFilter:
hard isolation only applies to a *chosen* context, so a doctor who had not
selected one could PATCH another clinic's room. Caught by
RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix.

35 tests, 97 assertions. Slot-mode frozen contract still green.

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

121 lines
4.9 KiB
PHP

<?php
namespace App\Branch\Entity;
use App\Branch\Repository\RoomRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* اتاق یک شعبه. برخلاف {@see BranchWorkingHours} جفت محیط دارد، چون uuidش از request
* می‌آید و بدون جفت، TenantFilter نمی‌تواند اتاق محیط دیگر را پنهان کند.
*
* جفت در سازنده از خودِ آدرس مشتق می‌شود نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
* نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند اتاقی را به محیط دیگری بچسباند.
*
* capacity یعنی چند بیمار هم‌زمان: اتاق تزریق سه‌تخته «یک منبع با ظرفیت ۳» است، نه
* سه منبع (بند ۶ مستند). تسک ۰۲ همین معنا را روی Resource تکرار می‌کند.
*/
#[ORM\Entity(repositoryClass: RoomRepository::class)]
#[ORM\Table(name: 'rooms')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_rooms_tenant')]
#[ORM\Index(columns: ['address_id', 'active'], name: 'idx_rooms_address')]
class Room
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
/** متن آزاد — نوع اتاق را خود کلینیک تعریف می‌کند، نه یک enum سراسری */
#[ORM\Column(name: 'room_type', type: 'string', length: 60, nullable: true)]
private ?string $roomType = null;
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $capacity = 1;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $floor = 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;
public function __construct(DoctorAddress $address, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->address = $address;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$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 getName(): string { return $this->name; }
public function getRoomType(): ?string { return $this->roomType; }
public function getCapacity(): int { return $this->capacity; }
public function getFloor(): ?string { return $this->floor; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setRoomType(?string $v): self { $this->roomType = $v; $this->touch(); return $this; }
public function setFloor(?string $v): self { $this->floor = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @throws \InvalidArgumentException روی ظرفیت کمتر از ۱ */
public function setCapacity(int $v): self
{
if ($v < 1) {
throw new \InvalidArgumentException('Room capacity must be at least 1.');
}
$this->capacity = $v;
$this->touch();
return $this;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'address_uuid' => $this->address->getUuid(),
'address_name' => $this->address->getName(),
'name' => $this->name,
'room_type' => $this->roomType,
'capacity' => $this->capacity,
'floor' => $this->floor,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}