refactor(branch): remove the branch domain, keep the address

Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.

What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".

BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.

The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.

Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:25:32 +03:30
co-authored by Claude Opus 5
parent 1c4f2a2451
commit dd284ec622
59 changed files with 566 additions and 3128 deletions
@@ -8,7 +8,7 @@ use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Entity\DoctorAddress;
@@ -39,7 +39,7 @@ class AvailabilityController extends BaseController
private readonly ServiceItemRepository $items,
private readonly WeeklyScheduleRepository $schedules,
private readonly DoctorRepository $doctors,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
) {}
#[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])]
@@ -5,7 +5,7 @@ namespace App\Appointment\Availability\Controller;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
@@ -41,7 +41,7 @@ class ResourceBlockController extends BaseController
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceOccupancyRepository $occupancy,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
@@ -16,7 +16,7 @@ use App\Pricing\Service\PriceSnapshotService;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Entity\ClinicResource;
@@ -51,7 +51,7 @@ class BookingController extends BaseController
private readonly UserRepository $users,
private readonly PricingEngine $pricing,
private readonly PriceSnapshotService $snapshots,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly AppointmentSegmentRepository $segments,
private readonly EntityManagerInterface $em,
@@ -7,7 +7,7 @@ use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Service\ResourceContext;
@@ -30,7 +30,7 @@ class AppointmentPlanController extends BaseController
private readonly SegmentTemplateRepository $templates,
private readonly ServiceItemRepository $items,
private readonly AppointmentPlanBuilder $builder,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly ResourceContext $resourceContext,
private readonly EntityManagerInterface $em,
) {}
-161
View File
@@ -1,161 +0,0 @@
<?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' => self::daysObject($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' => self::daysObject($days),
]);
}
/**
* کلیدهای ۰..۶ پشت‌سرهم‌اند، پس json_encode آرایهٔ PHP را به **آرایهٔ JSON**
* تبدیل می‌کرد نه به شیئی با کلیدهای "0".."6". کلاینت با `days["0"]` هر دو را
* می‌خواند، ولی شکل پاسخ ناپایدار می‌شد: کافی بود یک روز جا بیفتد تا همان فیلد
* شیء برگردد. (کلید رشته‌ایِ عددی هم چاره نیست — PHP خودش به int برش می‌گرداند.)
*
* @param array<int, list<array<string, mixed>>> $days
*/
private static function daysObject(array $days): \stdClass
{
return (object) $days;
}
}
-120
View File
@@ -1,120 +0,0 @@
<?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
@@ -1,105 +0,0 @@
<?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
@@ -1,120 +0,0 @@
<?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,
];
}
}
@@ -1,70 +0,0 @@
<?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
@@ -1,78 +0,0 @@
<?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;
}
}
@@ -1,20 +0,0 @@
<?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
@@ -1,112 +0,0 @@
<?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
@@ -1,197 +0,0 @@
<?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;
}
}
@@ -3,7 +3,7 @@
namespace App\ClinicService\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Repository\CatalogCategoryIncludeRepository;
@@ -50,7 +50,7 @@ class ServiceCatalogController extends BaseController
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly ServiceSelectionValidator $validator,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly CatalogCategoryIncludeRepository $includes,
private readonly CategoryClosureResolver $closure,
@@ -0,0 +1,46 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Service\AddressResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* محل‌های نوبت‌دهی محیط جاری — فهرست، فقط برای انتخاب.
*
* جانشین `GET /api/v1/branches` است. مفهوم «شعبه» از محصول حذف شد، ولی خودِ آدرس
* نمی‌رود: هر منبع، لیست قیمت و نوبت به یک آدرس بسته است. ساخت و ویرایش آدرس همان‌جایی
* است که همیشه بود (ClinicController و AppointmentSettingsController)؛ اینجا فقط
* خوانده می‌شود تا فرم‌های منبع و لیست قیمت بتوانند یکی را انتخاب کنند.
*/
#[OA\Tag(name: 'Doctor')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AddressController extends BaseController
{
public function __construct(
private readonly AddressResolver $addresses,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
#[Route('/api/v1/addresses', name: 'address_list', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', 'view');
return $this->success(array_map(
static fn (DoctorAddress $a): array => $a->toArray(),
$this->addresses->listForContext($user),
));
}
}
@@ -77,7 +77,7 @@ class DoctorAddressRepository extends ServiceEntityRepository
* `doctor_addresses` جفت محیط ندارد (عمداً — در GlobalTables::ENTITIES ثبت شده)
* پس TenantFilter رویش اعمال نمی‌شود و `findOneBy(['uuid' => …])` آدرس محیط دیگر
* را هم برمی‌گرداند. هر مسیری که uuid آدرس را از request می‌گیرد باید از این
* متد یا از {@see \App\Branch\Service\BranchResolver} رد شود.
* متد یا از {@see \App\Doctor\Service\AddressResolver} رد شود.
*/
public function findByUuidForEntityPair(string $uuid, string $entityType, int $entityId): ?DoctorAddress
{
@@ -1,6 +1,6 @@
<?php
namespace App\Branch\Service;
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\DoctorAddress;
@@ -12,14 +12,18 @@ use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* تک‌نقطهٔ تبدیل «uuid شعبه در request» به یک {@see DoctorAddress} از محیط جاری.
* تک‌نقطهٔ تبدیل «uuid محل نوبت‌دهی در request» به یک {@see DoctorAddress} از محیط جاری.
*
* لازم است چون doctor_addresses جفت (entity_type, entity_id) ندارد و در
* GlobalTables::ENTITIES سراسری اعلام شده، پس TenantFilter رویش کار نمی‌کند:
* findOneBy(['uuid' => ]) آدرس کلینیک دیگری را هم برمی‌گرداند. هر سه کنترلر این
* دامنه از اینجا رد می‌شوند تا این بررسی جایی جا نیفتد.
* findOneBy(['uuid' => ]) آدرس کلینیک دیگری را هم برمی‌گرداند. هر کنترلری که
* address_uuid می‌گیرد از اینجا رد می‌شود تا این بررسی جایی جا نیفتد.
*
* قبلاً `App\Branch\Service\BranchResolver` بود. با حذف مفهوم «شعبه» از محصول، اسم
* و خانه‌اش عوض شد ولی خودش نمی‌توانست حذف شود: موتور رزرو، دسترس‌پذیری، قیمت و
* کاتالوگ همگی از همین رد می‌شوند.
*/
final class BranchResolver
final class AddressResolver
{
public function __construct(
private readonly DoctorAddressRepository $addresses,
@@ -63,13 +67,13 @@ final class BranchResolver
$address = $this->addresses->findByUuidForEntityPair($addressUuid, $entityType, $entityId);
if ($address === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'محل نوبت‌دهی یافت نشد', 404);
}
return $address;
}
/** @return DoctorAddress[] شعبه‌های محیط جاری */
/** @return DoctorAddress[] محل‌های نوبت‌دهی محیط جاری */
public function listForContext(User $user): array
{
[$entityType, $entityId] = $this->pair($user);
+2 -2
View File
@@ -4,7 +4,7 @@ namespace App\Pricing\Controller;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Pricing\Entity\PriceList;
@@ -36,7 +36,7 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
@@ -3,7 +3,6 @@
namespace App\Resource\Command;
use App\Appointment\Entity\WeeklySchedule;
use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
@@ -26,7 +25,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
#[AsCommand(
name: 'app:resource:backfill',
description: 'Bridge existing doctors, staff and rooms to clinic resources',
description: 'Bridge existing doctors and staff to clinic resources',
)]
class BackfillResourceCommand extends Command
{
@@ -53,7 +52,7 @@ class BackfillResourceCommand extends Command
$io->note('Dry run — nothing will be written. Re-run with --force to apply.');
}
$created = ['room' => 0, 'staff' => 0, 'doctor' => 0];
$created = ['staff' => 0, 'doctor' => 0];
$skipped = [];
/** @var list<array{0: string, 1: string, 2: string}> $rows */
$rows = [];
@@ -65,7 +64,7 @@ class BackfillResourceCommand extends Command
$addressesByPair = array_intersect_key($addressesByPair, [$only => true]);
if ($addressesByPair === []) {
$io->warning(sprintf('محیط «%s» هیچ شعبه‌ای ندارد.', $only));
$io->warning(sprintf('محیط «%s» هیچ محل نوبت‌دهی‌ای ندارد.', $only));
return Command::SUCCESS;
}
@@ -78,22 +77,6 @@ class BackfillResourceCommand extends Command
$this->linker->systemType($entityType, (int) $entityId, $code);
}
// ── اتاق‌ها: آدرسشان را خودشان دارند، پس بی‌ابهام‌اند ──────────────────
foreach ($addresses as $address) {
foreach ($this->em->getRepository(Room::class)->findForAddress($address) as $room) {
if ($this->resources->findForSubject($room) !== null) {
continue;
}
$rows[] = ['room', (string) $room->getName(), (string) ($address->getName() ?? '—')];
$created['room']++;
if ($force) {
$this->linker->link($room, $address, $room->getName());
}
}
}
// ── پرسنل: هیچ ستونی آدرسش را نمی‌گوید ────────────────────────────────
$staffMembers = $this->em->getRepository(ClinicStaff::class)
->findBy(['entityType' => $entityType, 'entityId' => (int) $entityId, 'active' => true]);
@@ -145,7 +128,7 @@ class BackfillResourceCommand extends Command
}
if ($rows !== []) {
$io->table(['نوع', 'نام', 'شعبه'], $rows);
$io->table(['نوع', 'نام', 'محل'], $rows);
}
foreach ($skipped as $reason) {
@@ -153,9 +136,8 @@ class BackfillResourceCommand extends Command
}
$io->success(sprintf(
'%s — اتاق: %d · پرسنل: %d · پزشک: %d',
'%s — پرسنل: %d · پزشک: %d',
$force ? 'ساخته شد' : 'ساخته می‌شود',
$created['room'],
$created['staff'],
$created['doctor'],
));
@@ -165,7 +147,7 @@ class BackfillResourceCommand extends Command
/**
* منبعِ پزشک از `location_id`های برنامهٔ هفتگی مشتق می‌شود: آنجا دقیقاً نوشته که
* این پزشک در کدام آدرس‌ها شیفت دارد. «اولین شعبهٔ محیط» حدس می‌بود.
* این پزشک در کدام آدرس‌ها شیفت دارد. «اولین محل نوبت‌دهی محیط» حدس می‌بود.
*
* یک پاس روی همهٔ برنامه‌ها، نه یک پاس به‌ازای هر محیط: محیطِ هر برنامه از خودش
* خوانده می‌شود.
+4 -13
View File
@@ -4,7 +4,6 @@ namespace App\Resource\Entity;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Branch\Entity\Room;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Staff\Entity\ClinicStaff;
@@ -22,7 +21,7 @@ use Symfony\Component\Uid\Uuid;
*
* ## پل، نه ادغام
*
* `Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
* `Doctor` و `ClinicStaff` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
* (`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاس
* یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش حداکثر **یکی** از سه ستون پل پر است؛
* منبعِ بدون پل یعنی دستگاه یا تجهیزات.
@@ -33,7 +32,6 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Index(columns: ['address_id', 'resource_type_id', 'active'], name: 'idx_resources_address_type')]
#[ORM\UniqueConstraint(name: 'uniq_resource_doctor_address', columns: ['doctor_id', 'address_id'])]
#[ORM\UniqueConstraint(name: 'uniq_resource_staff_address', columns: ['staff_id', 'address_id'])]
#[ORM\UniqueConstraint(name: 'uniq_resource_room', columns: ['room_id'])]
class ClinicResource
{
use TenantOwnedTrait;
@@ -86,10 +84,6 @@ class ClinicResource
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ClinicStaff $staff = null;
#[ORM\ManyToOne(targetEntity: Room::class)]
#[ORM\JoinColumn(name: 'room_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Room $room = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
@@ -149,7 +143,6 @@ class ClinicResource
public function getAttributes(): array { return $this->attributes ?? []; }
public function getDoctor(): ?Doctor { return $this->doctor; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getRoom(): ?Room { return $this->room; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -204,7 +197,7 @@ class ClinicResource
*
* @throws \InvalidArgumentException روی پل دوم
*/
public function linkTo(Doctor|ClinicStaff|Room $subject): self
public function linkTo(Doctor|ClinicStaff $subject): self
{
if ($this->subject() !== null) {
throw new \InvalidArgumentException('A resource can bridge to at most one subject.');
@@ -213,7 +206,6 @@ class ClinicResource
match (true) {
$subject instanceof Doctor => $this->doctor = $subject,
$subject instanceof ClinicStaff => $this->staff = $subject,
$subject instanceof Room => $this->room = $subject,
};
$this->touch();
@@ -222,9 +214,9 @@ class ClinicResource
}
/** موجودیت اصلی پشت این منبع؛ `null` یعنی دستگاه/تجهیزات. */
public function subject(): Doctor|ClinicStaff|Room|null
public function subject(): Doctor|ClinicStaff|null
{
return $this->doctor ?? $this->staff ?? $this->room;
return $this->doctor ?? $this->staff;
}
private function assertMinutes(int $v, string $field): int
@@ -257,7 +249,6 @@ class ClinicResource
'subject_kind' => match (true) {
$this->doctor !== null => 'doctor',
$this->staff !== null => 'staff',
$this->room !== null => 'room',
default => null,
},
'subject_uuid' => $subject?->getUuid(),
@@ -5,7 +5,6 @@ namespace App\Resource\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Branch\Entity\Room;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Staff\Entity\ClinicStaff;
@@ -160,12 +159,11 @@ class ClinicResourceRepository extends ServiceEntityRepository
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
}
public function findForSubject(Doctor|ClinicStaff|Room $subject, ?DoctorAddress $address = null): ?ClinicResource
public function findForSubject(Doctor|ClinicStaff $subject, ?DoctorAddress $address = null): ?ClinicResource
{
$field = match (true) {
$subject instanceof Doctor => 'doctor',
$subject instanceof ClinicStaff => 'staff',
$subject instanceof Room => 'room',
};
$qb = $this->createQueryBuilder('r')
@@ -173,7 +171,7 @@ class ClinicResourceRepository extends ServiceEntityRepository
->setParameter('subject', $subject);
// اتاق فقط در یک آدرس است، پس آدرس برایش شرط اضافه نیست.
if ($address !== null && !$subject instanceof Room) {
if ($address !== null) {
$qb->andWhere('r.address = :address')->setParameter('address', $address);
}
@@ -186,12 +184,11 @@ class ClinicResourceRepository extends ServiceEntityRepository
*
* @return ClinicResource[]
*/
public function findAllForSubject(Doctor|ClinicStaff|Room $subject): array
public function findAllForSubject(Doctor|ClinicStaff $subject): array
{
$field = match (true) {
$subject instanceof Doctor => 'doctor',
$subject instanceof ClinicStaff => 'staff',
$subject instanceof Room => 'room',
};
return $this->createQueryBuilder('r')
@@ -2,7 +2,6 @@
namespace App\Resource\Service;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceException;
use App\Resource\Repository\NationalHolidayRepository;
@@ -34,7 +33,6 @@ final class ResourceAvailabilityService
public function __construct(
private readonly ResourceCalendarRepository $calendars,
private readonly ResourceExceptionRepository $exceptions,
private readonly BranchWorkingHoursRepository $branchHours,
private readonly NationalHolidayRepository $holidays,
private readonly TenantHolidayOverrideRepository $overrides,
) {}
@@ -52,7 +50,6 @@ final class ResourceAvailabilityService
// شیفت‌ها و ساعت شعبه یک بار خوانده می‌شوند، نه per روز.
$shiftsByDay = $this->shiftsByDay($resource);
$branchByDay = $this->branchHoursByDay($resource);
$holidayMap = $this->holidays->mapForRange($startDay, $endDay);
$overrideMap = $this->overrides->mapForRange(
@@ -76,7 +73,6 @@ final class ResourceAvailabilityService
$day,
$timezone,
$shiftsByDay,
$branchByDay,
$holidayMap,
$overrideMap,
$exceptions,
@@ -115,7 +111,6 @@ final class ResourceAvailabilityService
$endDay,
);
$branchByDay = $this->branchHoursByDay($first);
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
$shiftsById = $this->calendars->findForResources($ids);
@@ -142,7 +137,6 @@ final class ResourceAvailabilityService
$day,
$timezone,
$shiftsByDay,
$branchByDay,
$holidayMap,
$overrideMap,
$exceptionsById[$id] ?? [],
@@ -157,7 +151,6 @@ final class ResourceAvailabilityService
/**
* @param array<int, list<TimeInterval>> $shiftsByDay
* @param array<int, list<TimeInterval>>|null $branchByDay
* @param array<int, \App\Resource\Entity\NationalHoliday> $holidayMap
* @param array<int, \App\Resource\Entity\TenantHolidayOverride> $overrideMap
* @param ResourceException[] $exceptions
@@ -167,7 +160,6 @@ final class ResourceAvailabilityService
int $midnight,
\DateTimeZone $timezone,
array $shiftsByDay,
?array $branchByDay,
array $holidayMap,
array $overrideMap,
array $exceptions,
@@ -180,7 +172,7 @@ final class ResourceAvailabilityService
}
if (!$resource->getAddress()->isActive()) {
return new DayAvailability($midnight, $dayOfWeek, [], ['branch_inactive']);
return new DayAvailability($midnight, $dayOfWeek, [], ['address_inactive']);
}
$override = $overrideMap[$midnight] ?? null;
@@ -201,26 +193,6 @@ final class ResourceAvailabilityService
return new DayAvailability($midnight, $dayOfWeek, [], ['no_shift']);
}
// شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «بسته»: شیفت منبع بی‌قید اعمال
// می‌شود تا دادهٔ موجود دقیقاً مثل امروز کار کند (قرارداد تسک ۰۱).
if ($branchByDay !== null) {
$branchWindows = $branchByDay[$dayOfWeek] ?? [];
if ($branchWindows === []) {
return new DayAvailability($midnight, $dayOfWeek, [], ['branch_closed']);
}
$intersected = TimeInterval::intersectAll($shifts, $branchWindows);
// شیفت هست ولی تقاطعش با ساعت شعبه خالی شد — این با «شیفتی نیست» فرق دارد
// و بدون دلیل صریح، پاسخِ خالی از یک باگ قابل تشخیص نیست.
if ($intersected === []) {
$reasons[] = 'outside_branch_hours';
}
$shifts = $intersected;
}
$absolute = array_map(
static fn (TimeInterval $i): TimeInterval => $i->minutesToAbsolute($midnight),
$shifts,
@@ -263,31 +235,6 @@ final class ResourceAvailabilityService
return array_map(TimeInterval::mergeAll(...), $byDay);
}
/**
* `null` یعنی این شعبه اصلاً ساعت کاری تعریف‌شده ندارد — که با «همهٔ روزها بسته»
* فرق دارد و نباید با آن یکی گرفته شود.
*
* @return array<int, list<TimeInterval>>|null
*/
private function branchHoursByDay(ClinicResource $resource): ?array
{
$rows = $this->branchHours->findForAddress($resource->getAddress());
if ($rows === []) {
return null;
}
$byDay = [];
foreach ($rows as $row) {
if (!$row->isActive()) {
continue;
}
$byDay[$row->getDayOfWeek()][] = new TimeInterval($row->getStartMinute(), $row->getEndMinute());
}
return array_map(TimeInterval::mergeAll(...), $byDay);
}
/** ۰=شنبه … ۶=جمعه — همان قرارداد بقیهٔ سامانه، نه `w` استاندارد PHP. */
public function dayOfWeek(int $timestamp, \DateTimeZone $timezone): int
+2 -2
View File
@@ -3,7 +3,7 @@
namespace App\Resource\Service;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Doctor\Service\AddressResolver;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourcePool;
@@ -30,7 +30,7 @@ use App\Shared\Tenant\TenantOwnershipChecker;
final class ResourceContext
{
public function __construct(
private readonly BranchResolver $branches,
private readonly AddressResolver $branches,
private readonly ResourceTypeRepository $types,
private readonly ClinicResourceRepository $resources,
private readonly SkillRepository $skills,
+5 -13
View File
@@ -2,7 +2,6 @@
namespace App\Resource\Service;
use App\Branch\Entity\Room;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
@@ -66,8 +65,8 @@ final class ResourceLinker
$this->pendingTypes = [];
}
/** منبعِ متناظر با یک موجودیت در یک شعبه؛ اگر نبود می‌سازد. */
public function link(Doctor|ClinicStaff|Room $subject, DoctorAddress $address, string $name): ClinicResource
/** منبعِ متناظر با یک موجودیت در یک محل نوبت‌دهی؛ اگر نبود می‌سازد. */
public function link(Doctor|ClinicStaff $subject, DoctorAddress $address, string $name): ClinicResource
{
$existing = $this->resources->findForSubject($subject, $address);
@@ -78,32 +77,25 @@ final class ResourceLinker
$code = match (true) {
$subject instanceof Doctor => ResourceType::CODE_DOCTOR,
$subject instanceof ClinicStaff => ResourceType::CODE_STAFF,
$subject instanceof Room => ResourceType::CODE_ROOM,
};
$type = $this->systemType($address->tenantEntityType(), $address->tenantEntityId(), $code);
$resource = new ClinicResource($address, $type, $name);
$resource->linkTo($subject);
// اتاق ظرفیت خودش را دارد؛ شخص همیشه ظرفیت ۱.
if ($subject instanceof Room) {
$resource->setCapacity($subject->getCapacity());
$resource->setActive($subject->isActive());
}
$this->em->persist($resource);
return $resource;
}
/** برعکس: منبع → موجودیت اصلی. `null` یعنی دستگاه/تجهیزات. */
public function subject(ClinicResource $resource): Doctor|ClinicStaff|Room|null
public function subject(ClinicResource $resource): Doctor|ClinicStaff|null
{
return $resource->subject();
}
/**
* غیرفعال شدن پرسنل/اتاق باید منبعش را هم غیرفعال کند، وگرنه در جستجوی وقتِ تسک ۰۶
* غیرفعال شدن پرسنل باید منبعش را هم غیرفعال کند، وگرنه در جستجوی وقتِ تسک ۰۶
* ظاهر می‌شود.
*
* عمداً فراخوانی صریح است و نه Doctrine lifecycle callback: آن callback در
@@ -113,7 +105,7 @@ final class ResourceLinker
* عکسش برقرار نیست: غیرفعال کردن منبع، پرسنل را غیرفعال نمی‌کند (پرسنل ممکن است
* فقط نقش اداری داشته باشد).
*/
public function syncActive(Doctor|ClinicStaff|Room $subject, bool $active): int
public function syncActive(Doctor|ClinicStaff $subject, bool $active): int
{
$touched = 0;