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>
121 lines
4.9 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|