Files
clinicpro/docs/new_feture/taskes/task-01-branch-room/architecture.md
T
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging
to it. That plan was wrong: the branch already exists and is called
`DoctorAddress`. It carries name, address, telephone, coordinates, city/province
FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system
already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id`
points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a
booking location, and nine CRUD endpoints plus four admin pages manage them.
A parallel table would mean two sources of truth for one physical place and a
branch that `location_id` never references.

So no `branches` table and no duplicate branch CRUD. Only the three genuinely
missing pieces:

- `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so
  existing rows need no backfill and no current behaviour changes. `active` is
  stored only — applying it to slot calculation is task 03, since touching
  `SlotCalculatorService` is off limits in this phase.
- `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight
  rather than "09:00" strings so range intersection stays arithmetic. PUT
  replaces all seven days; validation of the whole week runs before any DELETE,
  so an invalid sixth day cannot wipe the five valid ones and then answer 422.
- `rooms`, with `capacity` as concurrency (a three-bed injection room is one
  resource with capacity 3, not three resources) and a deletion-guard iterator
  so tasks 02 and 07 can add reasons without editing RoomService.

`BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`;
TenantSchemaCoverageTest rejected it correctly, because that root is itself
declared global. It now carries a real tenant pair instead, derived in the
constructor from the address's `type` — a total mapping, and the address is only
ever listed in its own context, so nothing is hidden wrongly.

RoomController checks ownership explicitly rather than trusting TenantFilter:
hard isolation only applies to a *chosen* context, so a doctor who had not
selected one could PATCH another clinic's room. Caught by
RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix.

35 tests, 97 assertions. Slot-mode frozen contract still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:28:04 +03:30

7.9 KiB

معماری — تسک ۰۱

نسخهٔ اول این فایل entity Branch، BranchController (CRUD شعبه)، BranchService و BackfillBranchCommand داشت. همه حذف شدند: «شعبه» = DoctorAddress و CRUDش از قبل وجود دارد. دلیل: _shared/branch-is-doctor-address.md.

ساختار فایل

src/Branch/
├── Controller/
│   ├── BranchWorkingHoursController.php   # GET/PUT ساعت کاری یک آدرس
│   └── RoomController.php                 # CRUD اتاق
├── Entity/
│   ├── BranchWorkingHours.php             # فرزند aggregate — بدون جفت tenant
│   └── Room.php                           # TenantOwnedTrait
├── Repository/
│   ├── BranchWorkingHoursRepository.php
│   └── RoomRepository.php
└── Service/
    ├── WorkingHoursService.php            # اعتبارسنجی + جایگزینی هفت روز
    ├── RoomService.php                     # ساخت/ویرایش/حذف + قواعد حذف
    └── BranchResolver.php                  # uuid آدرس → DoctorAddress در محیط جاری

src/Doctor/Entity/DoctorAddress.php        # + active + timezone

assets/admin/pages/
├── BranchesPage.tsx                       # لیست شعبه‌های محیط جاری + دو اکشن
├── BranchWorkingHoursPage.tsx
└── BranchRoomsPage.tsx

دامنهٔ جدید Branch است نه Doctor، چون BranchWorkingHours و Room مفاهیم مکان‌اند و تسک‌های ۰۲/۰۳ منابع را هم روی همین دامنه می‌سازند. DoctorAddress سرِ جایش در Doctor می‌ماند — جابه‌جا کردنش namespace را می‌شکند بدون هیچ سودی.

BranchResolver — چرا لازم است

doctor_addresses ستون entity_type/entity_id ندارد، پس TenantFilter رویش اعمال نمی‌شود. یعنی findOneBy(['uuid' => $uuid]) آدرس محیط دیگر را هم برمی‌گرداند. هر endpoint جدیدی که با uuid آدرس شروع می‌شود باید محیط را دستی بررسی کند — همان کاری که clinic/{uuid}/addresses با findByUuidAndClinic() می‌کند.

یک نقطهٔ متمرکز به‌جای تکرار در سه کنترلر:

final class BranchResolver
{
    public function __construct(
        private readonly DoctorAddressRepository $addresses,
        private readonly EntityContextResolver   $context,
    ) {}

    /** @throws AppException 404 وقتی آدرس در محیط جاری نیست */
    public function resolve(string $addressUuid): DoctorAddress
    {
        $address = $this->addresses->findOneBy(['uuid' => $addressUuid]);
        if ($address === null || !$this->belongsToCurrentContext($address)) {
            throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
        }
        return $address;
    }
}

۴۰۴ نه ۴۰۳ — همان رفتار TenantFilter: وجود دادهٔ محیط دیگر لو نمی‌رود.

ساعت کاری شعبه

جدول جداست نه JSON مثل WeeklySchedule، چون تسک ۰۳ باید WHERE address_id = ? AND day_of_week = ? بزند بدون decode کردن JSON برای هر روز از ۹۰ روز.

#[ORM\Entity]
#[ORM\Table(name: 'branch_working_hours')]
#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])]
class BranchWorkingHours
{
    private DoctorAddress $address;
    private int $dayOfWeek;    // 0=شنبه … 6=جمعه — همان قرارداد SlotCalculatorService
    private int $startMinute;  // دقیقه از نیمه‌شب، 0..1440
    private int $endMinute;
    private int $sequence;     // بازهٔ چندم آن روز (صبح/عصر)
    private bool $active = true;
}

startMinute/endMinute عدد است نه رشتهٔ "08:30"، تا تقاطع در تسک ۰۶ حسابی باشد نه رشته‌ای. تبدیل به H:i فقط در toArray().

WorkingHoursService — جایگزینی کامل، نه تفاضلی

public function replace(DoctorAddress $address, array $days): array
{
    $rows = $this->validate($days);                 // اول همه را اعتبارسنجی کن
    $this->repository->deleteForAddress($address);  // بعد پاک کن
    foreach ($rows as $row) { $this->em->persist(...); }
    $this->em->flush();
}

اعتبارسنجی قبل از حذف اتفاق می‌افتد؛ وگرنه یک بازهٔ نامعتبر در روز ششم، پنج روز درست را هم پاک می‌کند و ۴۲۲ برمی‌گرداند. PUT semantics: بدنه تمام حقیقت است، آرایهٔ خالی = شعبه کامل بسته.

قواعد اعتبارسنجی: 0 <= start < end <= 1440 · هیچ دو بازهٔ هم‌پوشان در یک روز (بازه‌ها را per روز sort و همسایه‌ها را مقایسه کن) · day_of_week ∈ 0..6.

اتاق

class Room
{
    use TenantOwnedTrait;
    private DoctorAddress $address;
    private string  $name;
    private ?string $roomType = null;   // متن آزاد — نوع اتاق را کلینیک تعریف می‌کند
    private int     $capacity = 1;      // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3)
    private ?string $floor = null;
    private bool    $active = true;
}

جفت tenant در سازنده از آدرس مشتق می‌شود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند. capacity از روز اول هست چون مستند بند ۶ صریح می‌گوید سه تخت = یک منبع با ظرفیت سه، نه سه منبع؛ تسک ۰۲ همین معنا را روی Resource تکرار می‌کند و اتاق را به‌عنوان resource_type=room منعکس می‌کند.

حذف اتاق در این تسک فقط active را چک می‌کند (اتاق فعال قابل حذف است، منبع هنوز وجود ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه می‌شود — آنجاست که Resource.room_id به وجود می‌آید. این را در checklist به‌عنوان با مقصد صریح ثبت کن.

پنل ادمین

سه صفحهٔ جدید، همه با الگوهای موجود (_shared/ui-conventions.md):

صفحه مسیر نکات
BranchesPage /admin/branches DataTable + PageHeader با backTo="/admin/settings-menu" · وضعیت در URL با useUrlState · هر ردیف دو اکشن: «ساعت کاری» و «اتاق‌ها»
BranchWorkingHoursPage /admin/branches/:addressUuid/working-hours <PageHeader backTo="/admin/branches"> · هفت کارت روز، هر کارت چند بازه با افزودن/حذف · ذخیره = یک PUT
BranchRoomsPage /admin/branches/:addressUuid/rooms DataTable + Modal برای ساخت/ویرایش + ConfirmDialog برای حذف

BranchesPage آدرس نمی‌سازد و ویرایش نمی‌کند — آن کار در ClinicDetailPage و DoctorDetailPage از قبل هست. این صفحه فقط دروازهٔ ساعت کاری و اتاق است، به‌علاوهٔ سوییچ active و انتخاب timezone.

نقش‌ها: RoleRoute roles={['clinic', 'doctor', 'secretary']} با permission={['appointment_settings', 'view']} — ساعت کاری شعبه از جنس تنظیمات نوبت است و مجوز جدید ساختن یعنی یک ستون تازه در جدول مجوزها بدون نیاز واقعی.

ورودی منو: یک آیتم در SettingsMenuPage.tsx کنار «تنظیمات نوبت‌دهی».