Files
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

153 lines
7.9 KiB
Markdown

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