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

> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و
> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل
> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_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()` می‌کند.

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

```php
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 برای هر روز از ۹۰ روز.

```php
#[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` — جایگزینی کامل، نه تفاضلی

```php
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`.

## اتاق

```php
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` کنار «تنظیمات نوبت‌دهی».
