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>
This commit is contained in:
hamed
2026-07-30 16:28:04 +03:30
co-authored by Claude Opus 5
parent a44cf8f9f7
commit eebb363b9f
24 changed files with 2262 additions and 248 deletions
+43
View File
@@ -17,6 +17,8 @@ class DoctorAddress
public const TYPE_PERSONAL = 'personal';
public const TYPE_CLINIC = 'clinic';
public const DEFAULT_TIMEZONE = 'Asia/Tehran';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -58,6 +60,12 @@ class DoctorAddress
#[ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Province $province = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(type: 'string', length: 40, options: ['default' => self::DEFAULT_TIMEZONE])]
private string $timezone = self::DEFAULT_TIMEZONE;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -99,6 +107,25 @@ class DoctorAddress
public function getLongitude(): ?float { return $this->longitude; }
public function getCity(): ?City { return $this->city; }
public function getProvince(): ?Province { return $this->province; }
public function isActive(): bool { return $this->active; }
public function getTimezone(): string { return $this->timezone; }
/** جفت محیط این آدرس — `rooms` و منابع تسک ۰۲ جفتشان را از همین می‌گیرند، نه از request. */
public function tenantEntityType(): string
{
return $this->type === self::TYPE_CLINIC ? 'clinic' : 'doctor';
}
public function tenantEntityId(): int
{
$id = $this->type === self::TYPE_CLINIC ? $this->clinicId : $this->doctor?->getId();
if ($id === null) {
throw new \LogicException('DoctorAddress without an owner cannot derive a tenant pair.');
}
return $id;
}
public function setName(?string $v): self { $this->name = $v; return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
@@ -107,6 +134,20 @@ class DoctorAddress
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
public function setProvince(?Province $v): self { $this->province = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @throws \InvalidArgumentException روی شناسهٔ ناشناختهٔ منطقهٔ زمانی */
public function setTimezone(string $v): self
{
if (!in_array($v, \DateTimeZone::listIdentifiers(), true)) {
throw new \InvalidArgumentException(sprintf('Unknown timezone "%s".', $v));
}
$this->timezone = $v;
$this->touch();
return $this;
}
private function touch(): void { $this->updatedAt = time(); }
@@ -125,6 +166,8 @@ class DoctorAddress
],
'address' => $this->address,
'telephone' => $this->telephone,
'active' => $this->active,
'timezone' => $this->timezone,
'city' => $this->city !== null ? [
'id' => (string) $this->city->getId(),
'name' => $this->city->getName(),