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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user