Files
clinicpro/src/Branch/Repository/RoomRepository.php
T
hamedandClaude Opus 5 eebb363b9f 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>
2026-07-30 16:28:04 +03:30

79 lines
2.3 KiB
PHP

<?php
namespace App\Branch\Repository;
use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Room>
*/
class RoomRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Room::class);
}
/**
* uuid از request می‌آید، ولی Room جفت محیط دارد پس TenantFilter اتاق محیط دیگر را
* پیش از رسیدن به اینجا حذف می‌کند — همان دلیلی که در TenantLookupInventoryTest
* برای این lookup ثبت شده.
*/
public function findByUuid(string $uuid): ?Room
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Room[] */
public function findForAddress(DoctorAddress $address): array
{
return $this->createQueryBuilder('r')
->where('r.address = :address')
->setParameter('address', $address)
->orderBy('r.name', 'ASC')
->getQuery()
->getResult();
}
public function countActiveForAddress(DoctorAddress $address): int
{
return (int) $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.address = :address')
->andWhere('r.active = true')
->setParameter('address', $address)
->getQuery()
->getSingleScalarResult();
}
/**
* @param int[] $addressIds
* @return array<int, int> شناسهٔ آدرس => تعداد اتاق فعال
*/
public function countActiveByAddressIds(array $addressIds): array
{
if ($addressIds === []) {
return [];
}
$rows = $this->createQueryBuilder('r')
->select('IDENTITY(r.address) AS address_id, COUNT(r.id) AS total')
->where('r.address IN (:ids)')
->andWhere('r.active = true')
->setParameter('ids', $addressIds)
->groupBy('r.address')
->getQuery()
->getArrayResult();
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row['address_id']] = (int) $row['total'];
}
return $counts;
}
}