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:
@@ -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(),
|
||||
|
||||
@@ -41,6 +41,65 @@ class DoctorAddressRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* آدرسهای یک محیط با جفت (entity_type, entity_id) — همان واژگانی که Room و منابع
|
||||
* تسک ۰۲ با آن ذخیره میشوند.
|
||||
*
|
||||
* قرینهٔ findForContext() است ولی Doctor لازم ندارد: در محیط کلینیک، آن متد
|
||||
* پارامتر doctor را نادیده میگیرد و مجبور کردن فراخوان به ساختن یک Doctor
|
||||
* الکی، همان نوع کدی است که بعداً کسی با getReference() پرش میکند.
|
||||
*
|
||||
* @return DoctorAddress[]
|
||||
*/
|
||||
public function findForEntityPair(string $entityType, int $entityId): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
|
||||
if ($entityType === 'clinic') {
|
||||
$qb->where('a.clinicId = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_CLINIC);
|
||||
} else {
|
||||
$qb->where('IDENTITY(a.doctor) = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
|
||||
}
|
||||
|
||||
return $qb->setParameter('entityId', $entityId)
|
||||
->orderBy('a.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* یک آدرس با uuid، محدود به محیط دادهشده.
|
||||
*
|
||||
* `doctor_addresses` جفت محیط ندارد (عمداً — در GlobalTables::ENTITIES ثبت شده)
|
||||
* پس TenantFilter رویش اعمال نمیشود و `findOneBy(['uuid' => …])` آدرس محیط دیگر
|
||||
* را هم برمیگرداند. هر مسیری که uuid آدرس را از request میگیرد باید از این
|
||||
* متد یا از {@see \App\Branch\Service\BranchResolver} رد شود.
|
||||
*/
|
||||
public function findByUuidForEntityPair(string $uuid, string $entityType, int $entityId): ?DoctorAddress
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.uuid = :uuid')
|
||||
->setParameter('uuid', $uuid);
|
||||
|
||||
if ($entityType === 'clinic') {
|
||||
$qb->andWhere('a.clinicId = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_CLINIC);
|
||||
} else {
|
||||
$qb->andWhere('IDENTITY(a.doctor) = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
|
||||
}
|
||||
|
||||
return $qb->setParameter('entityId', $entityId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findOneByClinic(int $clinicId): ?DoctorAddress
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
|
||||
Reference in New Issue
Block a user