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>
This commit is contained in:
hamed
2026-07-30 16:28:04 +03:30
co-authored by Claude Opus 5
parent a44cf8f9f7
commit eebb363b9f
24 changed files with 2262 additions and 248 deletions
@@ -1,101 +1,111 @@
# معماری — تسک ۰۱
> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و
> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل
> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
## ساختار فایل
```
src/Branch/
├── Controller/
│ ├── BranchController.php # CRUD شعبه + ساعت کاری
│ └── RoomController.php # CRUD اتاق
│ ├── BranchWorkingHoursController.php # GET/PUT ساعت کاری یک آدرس
│ └── RoomController.php # CRUD اتاق
├── Entity/
│ ├── Branch.php
── BranchWorkingHours.php
│ └── Room.php
│ ├── BranchWorkingHours.php # فرزند aggregate — بدون جفت tenant
── Room.php # TenantOwnedTrait
├── Repository/
│ ├── BranchRepository.php
│ ├── BranchWorkingHoursRepository.php
│ └── RoomRepository.php
── Service/
├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف
── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز
└── Command/
└── BackfillBranchCommand.php # app:branch:backfill
── Service/
├── WorkingHoursService.php # اعتبارسنجی + جایگزینی هفت روز
── RoomService.php # ساخت/ویرایش/حذف + قواعد حذف
└── BranchResolver.php # uuid آدرس → DoctorAddress در محیط جاری
src/Doctor/Entity/DoctorAddress.php # + active + timezone
assets/admin/pages/
├── BranchesPage.tsx
├── BranchFormPage.tsx # شامل تب ساعت کاری
└── RoomsPage.tsx
├── BranchesPage.tsx # لیست شعبه‌های محیط جاری + دو اکشن
├── BranchWorkingHoursPage.tsx
└── BranchRoomsPage.tsx
```
## لایه‌بندی
دامنهٔ جدید `Branch` است نه `Doctor`، چون `BranchWorkingHours` و `Room` مفاهیم مکان‌اند و
تسک‌های ۰۲/۰۳ منابع را هم روی همین دامنه می‌سازند. `DoctorAddress` سرِ جایش در `Doctor`
می‌ماند — جابه‌جا کردنش namespace را می‌شکند بدون هیچ سودی.
`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس.
همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمال‌سازی ساعت) در `BranchService` و
`WorkingHoursService`.
## `BranchResolver` — چرا لازم است
`doctor_addresses` **ستون `entity_type`/`entity_id` ندارد**، پس `TenantFilter` رویش اعمال
نمی‌شود. یعنی `findOneBy(['uuid' => $uuid])` آدرس محیط دیگر را هم برمی‌گرداند. هر endpoint
جدیدی که با uuid آدرس شروع می‌شود باید محیط را **دستی** بررسی کند — همان کاری که
`clinic/{uuid}/addresses` با `findByUuidAndClinic()` می‌کند.
یک نقطهٔ متمرکز به‌جای تکرار در سه کنترلر:
```php
final class BranchService
final class BranchResolver
{
public function __construct(
private readonly BranchRepository $branches,
private readonly RoomRepository $rooms,
private readonly EntityManagerInterface $em,
private readonly DoctorAddressRepository $addresses,
private readonly EntityContextResolver $context,
) {}
public function create(EntityContext $ctx, BranchInput $input): Branch
/** @throws AppException 404 وقتی آدرس در محیط جاری نیست */
public function resolve(string $addressUuid): DoctorAddress
{
$branch = new Branch($input->name);
$branch->assignTenant($ctx); // ← اجباری، وگرنه flush می‌شکند
// ...
}
/** حذف فقط وقتی هیچ اتاق یا منبعِ فعالی به شعبه وصل نیست. */
public function delete(Branch $branch): void
{
if ($this->rooms->countActiveByBranch($branch) > 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شعبه دارای اتاق فعال است', 422);
$address = $this->addresses->findOneBy(['uuid' => $addressUuid]);
if ($address === null || !$this->belongsToCurrentContext($address)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
}
// ...
return $address;
}
}
```
## رابطهٔ Branch با DoctorAddress
`DoctorAddress` حذف نمی‌شود. یک ستون `branch_id` تهی‌پذیر می‌گیرد:
```
DoctorAddress.branch_id ──▶ branches.id (nullable, ON DELETE SET NULL)
```
دلیل: `location_id` در JSON برنامهٔ هفتگی به `doctor_addresses.id` اشاره دارد و در
`SlotCalculatorService` و `AppointmentController::bookingLocations()` و سایت عمومی مصرف می‌شود.
تغییر آن قرارداد یعنی شکستن سه کلاینت. پس شعبه یک **لایهٔ بالاتر** می‌نشیند و آدرس به آن
لینک می‌شود، نه برعکس.
`BackfillBranchCommand` برای هر محیطی که آدرس دارد یک شعبه با نام آدرس می‌سازد و
`branch_id` را پر می‌کند. dry-run پیش‌فرض، `--force` برای اجرا.
**۴۰۴ نه ۴۰۳** — همان رفتار `TenantFilter`: وجود دادهٔ محیط دیگر لو نمی‌رود.
## ساعت کاری شعبه
مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند
`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و decode کردن JSON برای هر روز از ۹۰ روز.
جدول جداست نه JSON مثل `WeeklySchedule`، چون تسک ۰۳ باید
`WHERE address_id = ? AND day_of_week = ?` بزند بدون decode کردن JSON برای هر روز از ۹۰ روز.
```php
#[ORM\Entity]
#[ORM\Table(name: 'branch_working_hours')]
#[ORM\UniqueConstraint(name: 'uniq_branch_day_seq', columns: ['branch_id', 'day_of_week', 'sequence'])]
#[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 int $sequence; // بازهٔ چندم آن روز (صبح/عصر)
private bool $active = true;
}
```
`startMinute`/`endMinute` به‌جای رشتهٔ `"08:30"` ذخیره می‌شوند تا مقایسه و تقاطع در تسک ۰۶
حسابی باشد نه رشته‌ای. تبدیل به `H:i` فقط در `toArray()`.
`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`.
## اتاق
@@ -103,23 +113,40 @@ class BranchWorkingHours
class Room
{
use TenantOwnedTrait;
private Branch $branch;
private DoctorAddress $address;
private string $name;
private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف می‌کند
private ?string $roomType = null; // متن آزاد — نوع اتاق را کلینیک تعریف می‌کند
private int $capacity = 1; // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3)
private ?string $floor = null;
private bool $active = true;
}
```
`capacity` از همین‌جا شروع می‌شود چون مستند بند ۶ صریح می‌گوید سه تخت = **یک منبع با
ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و اتاق را
به‌عنوان یک `Resource` با `resource_type=room` منعکس می‌کند.
جفت tenant در **سازنده از آدرس مشتق** می‌شود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی
نمی‌تواند فراموشش کند. `capacity` از روز اول هست چون مستند بند ۶ صریح می‌گوید سه تخت =
**یک منبع با ظرفیت سه**، نه سه منبع؛ تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و
اتاق را به‌عنوان `resource_type=room` منعکس می‌کند.
حذف اتاق در این تسک فقط `active` را چک می‌کند (اتاق فعال قابل حذف است، منبع هنوز وجود
ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه می‌شود — آنجاست که
`Resource.room_id` به وجود می‌آید. این را در checklist به‌عنوان ⏳ با مقصد صریح ثبت کن.
## پنل ادمین
- `BranchesPage.tsx``DataTable` + `PageHeader` با `backTo`، وضعیت لیست در URL با `useUrlState`
- `BranchFormPage.tsx` — دو تب: مشخصات / ساعت کاری. `SearchableSelect` برای شهر
(هرگز `<select>` بومی)
- `RoomsPage.tsx` — زیرصفحهٔ شعبه، `<BackButton fallback="/admin/branches" />`
- مسیرها در `App.tsx`: `/admin/branches`, `/admin/branches/new`, `/admin/branches/:uuid`,
`/admin/branches/:uuid/rooms`
سه صفحهٔ جدید، همه با الگوهای موجود (`_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` کنار «تنظیمات نوبت‌دهی».