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
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Branch\Controller;
use App\Auth\Entity\User;
use App\Branch\Entity\Room;
use App\Branch\Repository\RoomRepository;
use App\Branch\Service\BranchResolver;
use App\Branch\Service\RoomService;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Context\EntityContext;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Branch')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class RoomController extends BaseController
{
public function __construct(
private readonly BranchResolver $branches,
private readonly RoomRepository $rooms,
private readonly RoomService $roomService,
private readonly TenantOwnershipChecker $ownership,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/** @param 'view'|'update' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
}
/**
* مالکیت صریح سنجیده می‌شود و به TenantFilter تکیه نمی‌کنیم: جداسازی سختِ فیلتر
* فقط روی محیطِ «انتخاب‌شده» اعمال می‌شود ({@see EntityContext::$chosen}) و پزشکی
* که هنوز محیطی برنگزیده، اتاق کلینیک دیگر را می‌دید — با تست
* RoomCrudTest::testForeignRoomIsNotFound گرفته شد.
*
* ۴۰۴ نه ۴۰۳، همان رفتار فیلتر: وجود دادهٔ محیط بیگانه لو نمی‌رود.
*/
private function requireRoom(User $user, string $uuid): Room
{
$room = $this->rooms->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($room === null || !$this->ownership->belongsToPair($entityType, $entityId, $room)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'اتاق یافت نشد', 404);
}
return $room;
}
#[Route('/api/v1/branch/{addressUuid}/rooms', name: 'branch_rooms_list', methods: ['GET'])]
public function list(#[CurrentUser] User $user, string $addressUuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$address = $this->branches->resolve($user, $addressUuid);
return $this->success(array_map(
static fn (Room $room): array => $room->toArray(),
$this->rooms->findForAddress($address),
));
}
#[Route('/api/v1/room', name: 'room_create', methods: ['POST'])]
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
}
// جفت محیط اتاق از همین آدرس مشتق می‌شود، نه از بدنهٔ درخواست.
$address = $this->branches->resolve($user, $data['address_uuid']);
$room = $this->roomService->create($address, $data);
return $this->success($room->toArray(), 201);
}
#[Route('/api/v1/room/{uuid}', name: 'room_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$room = $this->roomService->update($this->requireRoom($user, $uuid), $data);
return $this->success($room->toArray());
}
#[Route('/api/v1/room/{uuid}', name: 'room_delete', methods: ['DELETE'])]
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$this->roomService->delete($this->requireRoom($user, $uuid));
return $this->success(null);
}
}