# معماری — تسک ۰۱ > ⛔ نسخهٔ اول این فایل 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` | `` · هفت کارت روز، هر کارت چند بازه با افزودن/حذف · ذخیره = یک 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` کنار «تنظیمات نوبت‌دهی».