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
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Branch\Controller;
use App\Auth\Entity\User;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Branch\Repository\RoomRepository;
use App\Branch\Service\BranchResolver;
use App\Branch\Service\WorkingHoursService;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Doctor\Entity\DoctorAddress;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
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;
/**
* شعبه = {@see DoctorAddress}. ساختن/ویرایش/حذف آدرس از قبل در ClinicController و
* AppointmentSettingsController هست و اینجا تکرار نمی‌شود؛ این کنترلر فقط چیزهایی را
* می‌دهد که آنجا نیست: فهرست شعبه‌های محیط جاری با شمارش، دو ویژگی تازهٔ
* active/timezone، و ساعت کاری هفتگی.
*/
#[OA\Tag(name: 'Branch')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BranchController extends BaseController
{
public function __construct(
private readonly BranchResolver $branches,
private readonly WorkingHoursService $workingHours,
private readonly BranchWorkingHoursRepository $hoursRepo,
private readonly RoomRepository $roomRepo,
private readonly EntityManagerInterface $em,
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);
}
#[Route('/api/v1/branches', name: 'branch_list', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$addresses = $this->branches->listForContext($user);
$ids = array_map(static fn (DoctorAddress $a): int => (int) $a->getId(), $addresses);
// دو کوئری گروهی به‌جای دو کوئری per شعبه.
$hourCounts = $this->hoursRepo->countByAddressIds($ids);
$roomCounts = $this->roomRepo->countActiveByAddressIds($ids);
$rows = array_map(static function (DoctorAddress $address) use ($hourCounts, $roomCounts): array {
$id = (int) $address->getId();
$row = $address->toArray();
$row['working_hours_defined'] = ($hourCounts[$id] ?? 0) > 0;
$row['rooms_count'] = $roomCounts[$id] ?? 0;
return $row;
}, $addresses);
return $this->success($rows);
}
/** فقط دو ویژگی شعبه‌ای؛ نام/آدرس/تلفن همان‌جایی ویرایش می‌شوند که همیشه. */
#[Route('/api/v1/branch/{addressUuid}', name: 'branch_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$address = $this->branches->resolve($user, $addressUuid);
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (array_key_exists('active', $data)) {
$address->setActive((bool) $data['active']);
}
if (array_key_exists('timezone', $data)) {
if (!is_string($data['timezone'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
}
try {
$address->setTimezone($data['timezone']);
} catch (\InvalidArgumentException) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
}
}
$this->em->flush();
return $this->success($address->toArray());
}
#[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_show', methods: ['GET'])]
public function showWorkingHours(#[CurrentUser] User $user, string $addressUuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$address = $this->branches->resolve($user, $addressUuid);
return $this->success([
'branch_uuid' => $address->getUuid(),
'timezone' => $address->getTimezone(),
'defined' => $this->workingHours->isDefined($address),
'days' => $this->workingHours->read($address),
]);
}
/**
* جایگزینی کامل هفت روز. آرایهٔ خالی یعنی شعبه کاملاً بسته است — نه «تغییری نده».
*/
#[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_replace', methods: ['PUT'])]
public function replaceWorkingHours(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$address = $this->branches->resolve($user, $addressUuid);
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['days'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد days الزامی است', 422, 'days');
}
$days = $this->workingHours->replace($address, $data['days']);
return $this->success([
'branch_uuid' => $address->getUuid(),
'timezone' => $address->getTimezone(),
'defined' => $days !== array_fill_keys(WorkingHoursService::DAYS, []),
'days' => $days,
]);
}
}
+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);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Branch\Entity;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* ساعت کاری هفتگی یک شعبه — و «شعبه» همان {@see DoctorAddress} است، نه جدولی جدا
* ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
*
* uuid ندارد (از request قابل ارجاع نیست) ولی جفت محیط دارد. اول به‌عنوان فرزند
* aggregate با ریشهٔ DoctorAddress ثبت شد و TenantSchemaCoverageTest درست ردش کرد:
* ریشه‌اش خودش در GlobalTables::ENTITIES سراسری است، پس آن مسیر هیچ تضمینی نمی‌داد.
* جفت گرفتن ممکن است چون type آدرس نگاشتی کامل به محیط دارد — personal ⇒ (doctor,
* doctorId) و clinic ⇒ (clinic, clinicId) — و آدرس هم فقط در همان محیط فهرست می‌شود،
* پس هیچ ردیفی بی‌دلیل پنهان نمی‌شود. نتیجه: TenantFilter واقعاً پوششش می‌دهد و
* {@see \App\Branch\Service\BranchResolver} لایهٔ دوم است نه تنها لایه.
*
* زمان‌ها «دقیقه از نیمه‌شب» است نه رشتهٔ "09:00": تقاطع دو بازه محاسبهٔ عددی است و
* مقایسهٔ رشته‌ای در «9:00» < «10:00» غلط جواب می‌دهد.
*/
#[ORM\Entity(repositoryClass: BranchWorkingHoursRepository::class)]
#[ORM\Table(name: 'branch_working_hours')]
#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])]
#[ORM\Index(columns: ['address_id', 'day_of_week', 'active'], name: 'idx_bwh_address_day')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bwh_tenant')]
class BranchWorkingHours
{
use TenantOwnedTrait;
public const MINUTES_IN_DAY = 1440;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
/** ۰=شنبه … ۶=جمعه — همان قرارداد SlotCalculatorService */
#[ORM\Column(name: 'day_of_week', type: 'smallint')]
private int $dayOfWeek;
/** بازهٔ چندم آن روز؛ سرور تخصیصش می‌دهد، نه کلاینت */
#[ORM\Column(type: 'smallint', options: ['default' => 0])]
private int $sequence = 0;
#[ORM\Column(name: 'start_minute', type: 'smallint')]
private int $startMinute;
#[ORM\Column(name: 'end_minute', type: 'smallint')]
private int $endMinute;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
public function __construct(
DoctorAddress $address,
int $dayOfWeek,
int $startMinute,
int $endMinute,
int $sequence = 0,
) {
$this->address = $address;
$this->dayOfWeek = $dayOfWeek;
$this->startMinute = $startMinute;
$this->endMinute = $endMinute;
$this->sequence = $sequence;
$this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
}
public function getId(): ?int { return $this->id; }
public function getAddress(): DoctorAddress { return $this->address; }
public function getDayOfWeek(): int { return $this->dayOfWeek; }
public function getSequence(): int { return $this->sequence; }
public function getStartMinute(): int { return $this->startMinute; }
public function getEndMinute(): int { return $this->endMinute; }
public function isActive(): bool { return $this->active; }
public function setActive(bool $v): self { $this->active = $v; return $this; }
public function toArray(): array
{
return [
'sequence' => $this->sequence,
'start_minute' => $this->startMinute,
'end_minute' => $this->endMinute,
'start_time' => self::formatMinute($this->startMinute),
'end_time' => self::formatMinute($this->endMinute),
'active' => $this->active,
];
}
/** ۱۴۴۰ به «۲۴:۰۰» تبدیل می‌شود، نه «۰۰:۰۰» — پایانِ روز است نه آغازش. */
public static function formatMinute(int $minute): string
{
return sprintf('%02d:%02d', intdiv($minute, 60), $minute % 60);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Branch\Entity;
use App\Branch\Repository\RoomRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* اتاق یک شعبه. برخلاف {@see BranchWorkingHours} جفت محیط دارد، چون uuidش از request
* می‌آید و بدون جفت، TenantFilter نمی‌تواند اتاق محیط دیگر را پنهان کند.
*
* جفت در سازنده از خودِ آدرس مشتق می‌شود نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
* نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند اتاقی را به محیط دیگری بچسباند.
*
* capacity یعنی چند بیمار هم‌زمان: اتاق تزریق سه‌تخته «یک منبع با ظرفیت ۳» است، نه
* سه منبع (بند ۶ مستند). تسک ۰۲ همین معنا را روی Resource تکرار می‌کند.
*/
#[ORM\Entity(repositoryClass: RoomRepository::class)]
#[ORM\Table(name: 'rooms')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_rooms_tenant')]
#[ORM\Index(columns: ['address_id', 'active'], name: 'idx_rooms_address')]
class Room
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
/** متن آزاد — نوع اتاق را خود کلینیک تعریف می‌کند، نه یک enum سراسری */
#[ORM\Column(name: 'room_type', type: 'string', length: 60, nullable: true)]
private ?string $roomType = null;
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $capacity = 1;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $floor = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(DoctorAddress $address, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->address = $address;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getAddress(): DoctorAddress { return $this->address; }
public function getName(): string { return $this->name; }
public function getRoomType(): ?string { return $this->roomType; }
public function getCapacity(): int { return $this->capacity; }
public function getFloor(): ?string { return $this->floor; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setRoomType(?string $v): self { $this->roomType = $v; $this->touch(); return $this; }
public function setFloor(?string $v): self { $this->floor = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @throws \InvalidArgumentException روی ظرفیت کمتر از ۱ */
public function setCapacity(int $v): self
{
if ($v < 1) {
throw new \InvalidArgumentException('Room capacity must be at least 1.');
}
$this->capacity = $v;
$this->touch();
return $this;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'address_uuid' => $this->address->getUuid(),
'address_name' => $this->address->getName(),
'name' => $this->name,
'room_type' => $this->roomType,
'capacity' => $this->capacity,
'floor' => $this->floor,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Branch\Repository;
use App\Branch\Entity\BranchWorkingHours;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BranchWorkingHours>
*/
class BranchWorkingHoursRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BranchWorkingHours::class);
}
/** @return BranchWorkingHours[] مرتب بر روز و سپس بازه */
public function findForAddress(DoctorAddress $address): array
{
return $this->createQueryBuilder('h')
->where('h.address = :address')
->setParameter('address', $address)
->orderBy('h.dayOfWeek', 'ASC')
->addOrderBy('h.sequence', 'ASC')
->getQuery()
->getResult();
}
public function deleteForAddress(DoctorAddress $address): int
{
return (int) $this->createQueryBuilder('h')
->delete()
->where('h.address = :address')
->setParameter('address', $address)
->getQuery()
->execute();
}
/**
* آدرس‌هایی که ساعت کاری تعریف‌شده دارند — برای نشان دادن وضعیت در لیست شعبه‌ها
* بدون N+۱ کوئری.
*
* @param int[] $addressIds
* @return array<int, int> شناسهٔ آدرس => تعداد بازه‌ها
*/
public function countByAddressIds(array $addressIds): array
{
if ($addressIds === []) {
return [];
}
$rows = $this->createQueryBuilder('h')
->select('IDENTITY(h.address) AS address_id, COUNT(h.id) AS total')
->where('h.address IN (:ids)')
->setParameter('ids', $addressIds)
->groupBy('h.address')
->getQuery()
->getArrayResult();
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row['address_id']] = (int) $row['total'];
}
return $counts;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Branch\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* تک‌نقطهٔ تبدیل «uuid شعبه در request» به یک {@see DoctorAddress} از محیط جاری.
*
* لازم است چون doctor_addresses جفت (entity_type, entity_id) ندارد و در
* GlobalTables::ENTITIES سراسری اعلام شده، پس TenantFilter رویش کار نمی‌کند:
* findOneBy(['uuid' => …]) آدرس کلینیک دیگری را هم برمی‌گرداند. هر سه کنترلر این
* دامنه از اینجا رد می‌شوند تا این بررسی جایی جا نیفتد.
*/
final class BranchResolver
{
public function __construct(
private readonly DoctorAddressRepository $addresses,
private readonly EntityContextResolver $contexts,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly RequestStack $requestStack,
) {}
/**
* جفت محیطِ این درخواست.
*
* منشی جدا حساب می‌شود چون EntityContextResolver او را مالک هیچ محیطی نمی‌شناسد —
* همان استثنایی که ClinicServiceController::resolveEntity() هم دارد. مجوزش جداگانه
* با denyUnlessGranted سنجیده می‌شود، اینجا فقط «کدام محیط» است.
*
* @return array{0: string, 1: int}
* @throws AppException وقتی محیطی حل نشود
*/
public function pair(User $user): array
{
[$type, $id] = $user->hasRole('ROLE_SECRETARY')
? $this->secretaryAccess->resolveOwnerEntity($user)
: $this->contexts->resolve($user, $this->requestedClinicUuid())->toEntityPair();
if ($id === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری انتخاب نشده است', 403);
}
return [$type, (int) $id];
}
/**
* ۴۰۴ می‌دهد نه ۴۰۳ — همان رفتار TenantFilter: وجودِ دادهٔ محیط دیگر لو نمی‌رود.
*
* @throws AppException
*/
public function resolve(User $user, string $addressUuid): DoctorAddress
{
[$entityType, $entityId] = $this->pair($user);
$address = $this->addresses->findByUuidForEntityPair($addressUuid, $entityType, $entityId);
if ($address === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
}
return $address;
}
/** @return DoctorAddress[] شعبه‌های محیط جاری */
public function listForContext(User $user): array
{
[$entityType, $entityId] = $this->pair($user);
return $this->addresses->findForEntityPair($entityType, $entityId);
}
private function requestedClinicUuid(): ?string
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return null;
}
$fromQuery = $request->query->get('clinic_uuid');
if (is_string($fromQuery) && $fromQuery !== '') {
return $fromQuery;
}
if (!in_array($request->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
return null;
}
$body = json_decode($request->getContent(), true);
return is_array($body) && is_string($body['clinic_uuid'] ?? null) && $body['clinic_uuid'] !== ''
? $body['clinic_uuid']
: null;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\Room;
use App\Shared\Exception\AppException;
/**
* دلیلی که یک اتاق را غیرقابل‌حذف می‌کند.
*
* الان هیچ پیاده‌سازی‌ای ندارد و این عمدی است: در این فاز اتاق هیچ وابستهٔ زنده‌ای
* ندارد. تسک ۰۲ (منبعِ فعال روی اتاق) و تسک ۰۷ (نوبت آیندهٔ آن منابع) هرکدام یک
* پیاده‌سازی اضافه می‌کنند و RoomService دست نمی‌خورد — به‌جای زنجیرهٔ if که هر تسک
* یک شرط به آن سنجاق کند.
*/
interface RoomDeletionGuardInterface
{
/** @throws AppException وقتی حذف مجاز نیست */
public function assertDeletable(Room $room): void;
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class RoomService
{
/**
* @param iterable<RoomDeletionGuardInterface> $deletionGuards
*/
public function __construct(
private readonly EntityManagerInterface $em,
#[AutowireIterator('app.room_deletion_guard')]
private readonly iterable $deletionGuards = [],
) {}
/** @param array<string, mixed> $data */
public function create(DoctorAddress $address, array $data): Room
{
$room = new Room($address, $this->assertName($data['name'] ?? null));
$this->applyOptional($room, $data);
$this->em->persist($room);
$this->em->flush();
return $room;
}
/** @param array<string, mixed> $data */
public function update(Room $room, array $data): Room
{
if (array_key_exists('name', $data)) {
$room->setName($this->assertName($data['name']));
}
$this->applyOptional($room, $data);
$this->em->flush();
return $room;
}
public function delete(Room $room): void
{
foreach ($this->deletionGuards as $guard) {
$guard->assertDeletable($room);
}
$this->em->remove($room);
$this->em->flush();
}
/** @param array<string, mixed> $data */
private function applyOptional(Room $room, array $data): void
{
if (array_key_exists('capacity', $data)) {
$room->setCapacity($this->assertCapacity($data['capacity']));
}
if (array_key_exists('room_type', $data)) {
$room->setRoomType($this->trimOrNull($data['room_type']));
}
if (array_key_exists('floor', $data)) {
$room->setFloor($this->trimOrNull($data['floor']));
}
if (array_key_exists('active', $data)) {
$room->setActive((bool) $data['active']);
}
}
private function assertName(mixed $value): string
{
$name = is_string($value) ? trim($value) : '';
if ($name === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'نام اتاق الزامی است', 422, 'name');
}
if (mb_strlen($name) > 120) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام اتاق حداکثر ۱۲۰ نویسه است', 422, 'name');
}
return $name;
}
private function assertCapacity(mixed $value): int
{
if (!is_numeric($value) || (int) $value < 1) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ظرفیت اتاق حداقل ۱ است', 422, 'capacity');
}
return (int) $value;
}
private function trimOrNull(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\BranchWorkingHours;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* ساعت کاری هفتگی شعبه — اعتبارسنجی و ذخیره.
*
* قرارداد نوشتن PUT است نه PATCH: بدنه تمام حقیقتِ هفت روز است و آرایهٔ خالی یعنی
* «شعبه کاملاً بسته». دلیل: ساعت کاری یک شکل واحد است، و merge تفاضلی روی هفت روز و
* چند بازه در هر روز، دو کلاینت هم‌زمان را به وضعیت‌های ناسازگار می‌رساند.
*
* «شعبهٔ بدون هیچ ساعت کاری» = تعریف‌نشده، نه همیشه‌باز. تسک ۰۳ در آن حالت به رفتار
* فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع) تا دادهٔ موجود دقیقاً مثل امروز کار کند.
*/
final class WorkingHoursService
{
public const DAYS = [0, 1, 2, 3, 4, 5, 6];
public function __construct(
private readonly BranchWorkingHoursRepository $hours,
private readonly EntityManagerInterface $em,
) {}
/**
* @return array<int, list<array<string, mixed>>> کلیدهای ۰..۶ همیشه هر هفت روز
*/
public function read(DoctorAddress $address): array
{
$result = array_fill_keys(self::DAYS, []);
foreach ($this->hours->findForAddress($address) as $row) {
$result[$row->getDayOfWeek()][] = $row->toArray();
}
return $result;
}
public function isDefined(DoctorAddress $address): bool
{
return $this->hours->findForAddress($address) !== [];
}
/**
* جایگزینی کامل هفت روز.
*
* @param array<int|string, mixed> $days نگاشت روز => فهرست بازه‌ها
* @return array<int, list<array<string, mixed>>>
* @throws AppException روی هر ورودی نامعتبر — پیش از هر تغییری در دیتابیس
*/
public function replace(DoctorAddress $address, array $days): array
{
$normalized = $this->validate($days);
// اعتبارسنجی کاملِ هر هفت روز قبل از DELETE: بازهٔ نامعتبر در روز ششم نباید
// شش روز درستِ قبلی را هم پاک کند و بعد ۴۲۲ برگرداند.
$this->hours->deleteForAddress($address);
foreach ($normalized as $dayOfWeek => $ranges) {
foreach ($ranges as $sequence => $range) {
$this->em->persist(new BranchWorkingHours(
$address,
$dayOfWeek,
$range['start_minute'],
$range['end_minute'],
$sequence,
));
}
}
$this->em->flush();
return $this->read($address);
}
/**
* @param array<int|string, mixed> $days
* @return array<int, list<array{start_minute: int, end_minute: int}>> مرتب‌شده، بدون هم‌پوشانی
* @throws AppException
*/
private function validate(array $days): array
{
$normalized = array_fill_keys(self::DAYS, []);
foreach ($days as $rawDay => $ranges) {
$day = $this->assertDay($rawDay);
if (!is_array($ranges)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بازه‌های روز %d باید یک آرایه باشد', $day),
422,
(string) $rawDay,
);
}
$normalized[$day] = $this->assertRanges($day, $ranges);
}
return $normalized;
}
private function assertDay(int|string $rawDay): int
{
if (!is_numeric($rawDay) || !in_array((int) $rawDay, self::DAYS, true)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'روز هفته باید عددی بین ۰ (شنبه) و ۶ (جمعه) باشد',
422,
'day_of_week',
);
}
return (int) $rawDay;
}
/**
* @param array<int|string, mixed> $ranges
* @return list<array{start_minute: int, end_minute: int}>
*/
private function assertRanges(int $day, array $ranges): array
{
$parsed = [];
foreach ($ranges as $range) {
if (!is_array($range)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بازهٔ روز %d ساختار درستی ندارد', $day),
422,
'start_minute',
);
}
$start = $this->assertMinute($range['start_minute'] ?? null, $day, 'start_minute');
$end = $this->assertMinute($range['end_minute'] ?? null, $day, 'end_minute');
if ($end <= $start) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('در روز %d، پایان بازه باید بعد از شروع آن باشد', $day),
422,
'end_minute',
);
}
$parsed[] = ['start_minute' => $start, 'end_minute' => $end];
}
usort($parsed, static fn (array $a, array $b): int => $a['start_minute'] <=> $b['start_minute']);
// sequence از همین ترتیب مشتق می‌شود، پس تشخیص هم‌پوشانی فقط مقایسهٔ همسایه‌هاست.
foreach ($parsed as $i => $range) {
if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بازه‌های روز %d با هم هم‌پوشانی دارند', $day),
422,
'start_minute',
);
}
}
return $parsed;
}
private function assertMinute(mixed $value, int $day, string $field): int
{
if (!is_numeric($value)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
sprintf('در روز %d مقدار %s الزامی است', $day, $field),
422,
$field,
);
}
$minute = (int) $value;
if ($minute < 0 || $minute > BranchWorkingHours::MINUTES_IN_DAY) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field),
422,
$field,
);
}
return $minute;
}
}
+43
View File
@@ -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')