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
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace App\Tests\Branch;
use App\Branch\Entity\Room;
class RoomCrudTest extends BranchTestCase
{
/** @param array<string, mixed> $body */
private function createRoom(\App\Auth\Entity\User $user, string $addressUuid, array $body = []): array
{
return $this->authJson('POST', '/api/v1/room', $user, $body + [
'address_uuid' => $addressUuid,
'name' => 'اتاق تزریق',
]);
}
public function testRoomIsCreatedWithTenantPairDerivedFromTheBranch(): void
{
[$clinicUser, $clinic, $address] = $this->clinicWithAddress();
$body = $this->createRoom($clinicUser, $address->getUuid(), ['capacity' => 3, 'floor' => '2']);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(3, $body['data']['capacity']);
self::assertSame('2', $body['data']['floor']);
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
self::assertSame('clinic', $room->getEntityType());
self::assertSame($clinic->getId(), $room->getEntityId());
}
public function testPersonalBranchRoomBelongsToTheDoctor(): void
{
[$doctorUser, $doctor, $address] = $this->doctorWithAddress();
$body = $this->createRoom($doctorUser, $address->getUuid());
self::assertSame(201, $this->responseCode());
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
self::assertSame('doctor', $room->getEntityType());
self::assertSame($doctor->getId(), $room->getEntityId());
}
public function testCapacityDefaultsToOne(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->createRoom($user, $address->getUuid());
self::assertSame(1, $body['data']['capacity']);
self::assertTrue($body['data']['active']);
}
public function testZeroCapacityIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->createRoom($user, $address->getUuid(), ['capacity' => 0]);
self::assertSame(422, $this->responseCode());
self::assertSame('capacity', $body['errors'][0]['field']);
}
public function testBlankNameIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('POST', '/api/v1/room', $user, [
'address_uuid' => $address->getUuid(),
'name' => ' ',
]);
self::assertSame(422, $this->responseCode());
self::assertSame('name', $body['errors'][0]['field']);
}
public function testMissingAddressUuidIsRejected(): void
{
[$user] = $this->doctorWithAddress();
$body = $this->authJson('POST', '/api/v1/room', $user, ['name' => 'اتاق']);
self::assertSame(422, $this->responseCode());
self::assertSame('address_uuid', $body['errors'][0]['field']);
}
/** جفت محیط از آدرس می‌آید، پس نمی‌شود اتاق را روی شعبهٔ محیط دیگر نشاند. */
public function testRoomCannotBeCreatedOnAForeignBranch(): void
{
[$doctorUser] = $this->doctorWithAddress();
[, , $foreignAddress] = $this->clinicWithAddress();
$this->createRoom($doctorUser, $foreignAddress->getUuid());
self::assertSame(404, $this->responseCode());
}
public function testRoomIsUpdated(): void
{
[$user, , $address] = $this->doctorWithAddress();
$created = $this->createRoom($user, $address->getUuid());
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, [
'name' => 'اتاق پانسمان',
'capacity' => 2,
'room_type' => 'پانسمان',
'active' => false,
]);
self::assertSame(200, $this->responseCode());
self::assertSame('اتاق پانسمان', $body['data']['name']);
self::assertSame(2, $body['data']['capacity']);
self::assertSame('پانسمان', $body['data']['room_type']);
self::assertFalse($body['data']['active']);
}
/** رشتهٔ خالی روی فیلد اختیاری یعنی «پاک کن»، نه ذخیرهٔ رشتهٔ خالی. */
public function testBlankOptionalFieldBecomesNull(): void
{
[$user, , $address] = $this->doctorWithAddress();
$created = $this->createRoom($user, $address->getUuid(), ['room_type' => 'تزریق']);
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['room_type' => '']);
self::assertNull($body['data']['room_type']);
}
public function testRoomIsDeleted(): void
{
[$user, , $address] = $this->doctorWithAddress();
$created = $this->createRoom($user, $address->getUuid());
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['name' => 'x']);
self::assertSame(404, $this->responseCode());
}
public function testForeignRoomIsNotFound(): void
{
[$clinicUser, , $clinicAddress] = $this->clinicWithAddress();
$created = $this->createRoom($clinicUser, $clinicAddress->getUuid());
[$doctorUser] = $this->doctorWithAddress();
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $doctorUser, ['name' => 'دزدیده‌شده']);
self::assertSame(404, $this->responseCode());
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $doctorUser);
self::assertSame(404, $this->responseCode());
}
public function testBranchRoomsAreListedForItsOwnerOnly(): void
{
[$user, , $address] = $this->doctorWithAddress();
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۱']);
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۲']);
$body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/rooms", $user);
self::assertSame(200, $this->responseCode());
self::assertCount(2, $body['data']);
[, , $foreignAddress] = $this->clinicWithAddress();
$this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/rooms", $user);
self::assertSame(404, $this->responseCode());
}
}