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
@@ -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;