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
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Tests\Branch;
use App\Doctor\Entity\DoctorAddress;
/**
* دو ویژگی تازهٔ شعبه (`active` / `timezone`) و فهرست شعبه‌های محیط جاری.
*/
class BranchFieldsTest extends BranchTestCase
{
/** ردیف‌های موجود بدون backfill درست می‌شوند؛ هیچ رفتار فعلی عوض نمی‌شود. */
public function testExistingBranchGetsSafeDefaults(): void
{
[$user, , $address] = $this->doctorWithAddress();
self::assertTrue($address->isActive());
self::assertSame(DoctorAddress::DEFAULT_TIMEZONE, $address->getTimezone());
$body = $this->authJson('GET', '/api/v1/branches', $user);
self::assertSame(200, $this->responseCode());
self::assertTrue($body['data'][0]['active']);
self::assertSame('Asia/Tehran', $body['data'][0]['timezone']);
}
public function testListReportsWorkingHoursAndRoomCounts(): void
{
[$user, , $address] = $this->doctorWithAddress();
$before = $this->authJson('GET', '/api/v1/branches', $user);
self::assertFalse($before['data'][0]['working_hours_defined']);
self::assertSame(0, $before['data'][0]['rooms_count']);
$this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
'days' => [1 => [['start_minute' => 540, 'end_minute' => 780]]],
]);
$this->authJson('POST', '/api/v1/room', $user, [
'address_uuid' => $address->getUuid(),
'name' => 'اتاق ۱',
]);
$after = $this->authJson('GET', '/api/v1/branches', $user);
self::assertTrue($after['data'][0]['working_hours_defined']);
self::assertSame(1, $after['data'][0]['rooms_count']);
}
/** فقط اتاق فعال شمرده می‌شود — اتاق غیرفعال ظرفیت واقعی شعبه نیست. */
public function testInactiveRoomIsNotCounted(): void
{
[$user, , $address] = $this->doctorWithAddress();
$room = $this->authJson('POST', '/api/v1/room', $user, [
'address_uuid' => $address->getUuid(),
'name' => 'اتاق بسته',
]);
$this->authJson('PATCH', "/api/v1/room/{$room['data']['uuid']}", $user, ['active' => false]);
$body = $this->authJson('GET', '/api/v1/branches', $user);
self::assertSame(0, $body['data'][0]['rooms_count']);
}
public function testListShowsOnlyTheCurrentContextBranches(): void
{
[$doctorUser, , $doctorAddress] = $this->doctorWithAddress('مطب شخصی');
$this->clinicWithAddress('شعبهٔ کلینیک بیگانه');
$body = $this->authJson('GET', '/api/v1/branches', $doctorUser);
self::assertCount(1, $body['data']);
self::assertSame($doctorAddress->getUuid(), $body['data'][0]['uuid']);
}
public function testBranchIsDeactivated(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['active' => false]);
self::assertSame(200, $this->responseCode());
self::assertFalse($body['data']['active']);
}
public function testTimezoneIsUpdated(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, [
'timezone' => 'Asia/Dubai',
]);
self::assertSame(200, $this->responseCode());
self::assertSame('Asia/Dubai', $body['data']['timezone']);
}
/** با DateTimeZone::listIdentifiers سنجیده می‌شود، نه با regex. */
public function testUnknownTimezoneIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['timezone' => 'Tehran']);
self::assertSame(422, $this->responseCode());
self::assertSame('timezone', $body['errors'][0]['field']);
}
public function testForeignBranchCannotBePatched(): void
{
[$doctorUser] = $this->doctorWithAddress();
[, , $foreignAddress] = $this->clinicWithAddress();
$this->authJson('PATCH', "/api/v1/branch/{$foreignAddress->getUuid()}", $doctorUser, ['active' => false]);
self::assertSame(404, $this->responseCode());
}
/** شمارش‌ها گروهی‌اند: تعداد کوئری‌ها با تعداد شعبه‌ها رشد نمی‌کند. */
public function testListQueryCountDoesNotGrowWithBranches(): void
{
// یک کرنل برای هر دو اندازه‌گیری، وگرنه reboot دادهٔ کوئری‌ها را می‌ریزد.
$this->client->disableReboot();
[$user, $doctor] = $this->doctorWithAddress();
$queriesForOne = $this->countQueries(
fn () => $this->authJson('GET', '/api/v1/branches', $user)
);
for ($i = 0; $i < 4; $i++) {
$extra = DoctorAddress::forDoctor($doctor);
$extra->setName("شعبهٔ $i");
$this->em->persist($extra);
}
$this->em->flush();
$queriesForFive = $this->countQueries(
fn () => $this->authJson('GET', '/api/v1/branches', $user)
);
self::assertCount(5, json_decode($this->client->getResponse()->getContent(), true)['data']);
self::assertSame($queriesForOne, $queriesForFive);
}
}