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,61 +1,97 @@
# تسک ۰۱ — شعبه (Branch) و اتاق (Room)
# تسک ۰۱ — شعبه و اتاق
**فاز:** ۱ (هسته) · **وابستگی:** · **زمان:** ۱۰-۱۲ ساعت
**فاز:** ۱ (هسته) · **وابستگی:** ۰۰ · **زمان:** ۶-۸ ساعت (بازتخمین — رجوع به بخش «تصحیح طرح»)
---
## ⛔ تصحیح طرح (اجرای ۱۴۰۵/۰۵/۰۸)
نسخهٔ اول این تسک یک جدول `branches` تازه می‌خواست و `doctor_addresses.branch_id` را به
آن وصل می‌کرد. **این طرح غلط بود: «شعبه» از قبل وجود دارد و نامش `DoctorAddress` است.**
| `branches` پیشنهادی | واقعیت `doctor_addresses` |
|---|---|
| `name` · `phone` · `address` | ✅ `name` · `telephone` · `address` |
| `city_id` · `province_id` | ✅ **FK به entity `City`/`Province`** — بهتر از ارجاع خام به `categories` که طرح اول می‌خواست |
| `latitude` · `longitude` | ✅ هر دو |
| جفت tenant | ⚠️ `DoctorAddress::forDoctor(Doctor)` / `forClinic(int $clinicId)` + ستون `type` — همان اطلاعات، با شکل دیگر |
| `timezone` | ❌ غایب |
| `active` | ❌ غایب |
| ساعت کاری | ❌ غایب — **واقعاً جدید** |
| اتاق | ❌ غایب — **واقعاً جدید** |
و این‌ها هم از قبل هستند:
- `WeeklySchedule.setting[day].sessions[].location_id``doctor_addresses.id`
- `GET /api/v1/appointment-booking-locations/{doctorUuid}` هر آدرس را «محل نوبت‌دهی» می‌نامد
- `DoctorAddressRepository::findForContext($doctor, $clinicId)` چند آدرس per محیط می‌دهد
- **۹ endpoint CRUD**: `clinic/{uuid}/addresses` (۴ عدد) و `clinic-pro/doctor-address*` (۵ عدد)
- UI ادمین در `ClinicDetailPage`، `ClinicFormPage`، `DoctorDetailPage`، `SettingsPage`
ساختن `branches` بالای این یعنی: دو جدول برای یک مکان فیزیکی، دو منبع حقیقت برای
نام/آدرس/تلفن/مختصات، هر مصرف‌کننده باید تصمیم بگیرد کدام را بخواند، و branchی که
`location_id` هرگز به آن اشاره نمی‌کند. نقض قاعدهٔ پروژه
(«اول بگرد، بعد توسعه بده، در آخر بساز»).
**پس در این تسک هیچ جدول `branches` ساخته نمی‌شود و هیچ endpoint CRUD شعبه اضافه نمی‌شود.**
---
## هدف
سطح سوم مکان را به مدل اضافه کن: امروز `(entity_type, entity_id)` می‌گوید داده مال کدام
محیط است، ولی نمی‌گوید در کدام **ساختمان** و کدام **اتاق**. مستند بند ۴ سه سطح می‌خواهد
و «منابع همیشه مال شعبه‌اند چون فیزیکی‌اند» — بدون شعبه، تسک ۰۲ جایی برای نشستن ندارد.
سه چیزِ واقعاً غایب را اضافه کن تا تسک‌های ۰۲ و ۰۳ جایی برای نشستن داشته باشند:
## وضعیت فعلی
- محل مراجعه امروز `DoctorAddress` است و `location_id` هر شیفت در
`WeeklySchedule.setting[day].sessions[].location_id` به `doctor_addresses.id` اشاره می‌کند
(`SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند).
- `Clinic` هیچ فیلد شعبه‌ای ندارد؛ یک آدرس متنی تخت دارد.
- ساعت کاری شعبه وجود ندارد — ساعت کاری فقط روی برنامهٔ پزشک است.
۱. `active` و `timezone` روی `DoctorAddress` — یک شعبهٔ بسته باید بتواند بسته شود، و
بند ۹ مستند ذخیره‌سازی UTC با نمایش محلی می‌خواهد.
۲. **ساعت کاری هفتگی شعبه** — امروز ساعت کاری فقط روی برنامهٔ پزشک است. تسک ۰۳ برای
کسر لایه‌ها به ساعت کاری شعبه نیاز دارد.
۳. **اتاق** — با ظرفیت هم‌زمان. تسک ۰۲ اتاق را به‌عنوان یک `resource_type` منعکس می‌کند.
## دامنه
**هست:** entity های `Branch` و `Room`، ساعت کاری هفتگی شعبه، CRUD پنل ادمین،
پل زدن `DoctorAddress.branch_id` برای اینکه شیفت‌های موجود بدون تغییر به شعبه نگاشت شوند.
**هست:** دو ستون روی `DoctorAddress` · جدول و entity `BranchWorkingHours` · جدول و entity
`Room` · سرویس اعتبارسنجی ساعت · endpoint ساعت کاری و CRUD اتاق · UI ادمین برای هر دو.
**نیست:** استفاده از شعبه در محاسبهٔ اسلات (تسک ۰۳)، اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
**نیست:** جدول `branches` (رد شد) · CRUD شعبه (موجود) · استفاده از ساعت شعبه در محاسبهٔ
اسلات (تسک ۰۳) · اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/branches` | لیست شعب محیط جاری (paginated) |
| POST | `/api/v1/branch` | ساخت شعبه |
| GET | `/api/v1/branch/{uuid}` | جزئیات + ساعت کاری |
| PATCH | `/api/v1/branch/{uuid}` | ویرایش |
| DELETE | `/api/v1/branch/{uuid}` | حذف (فقط بدون منبع/اتاق فعال) |
| PUT | `/api/v1/branch/{uuid}/working-hours` | ثبت ساعت کاری هفتگی |
| GET | `/api/v1/branch/{uuid}/rooms` | اتاق‌های شعبه |
| POST/PATCH/DELETE | `/api/v1/room[/{uuid}]` | CRUD اتاق |
| GET | `/api/v1/branch/{addressUuid}/working-hours` | ساعت کاری هفتگی یک شعبه |
| PUT | `/api/v1/branch/{addressUuid}/working-hours` | جایگزینی کامل هفت روز |
| GET | `/api/v1/branch/{addressUuid}/rooms` | اتاق‌های شعبه |
| POST | `/api/v1/room` | ساخت اتاق |
| PATCH | `/api/v1/room/{uuid}` | ویرایش |
| DELETE | `/api/v1/room/{uuid}` | حذف (فقط بدون منبع فعال — گارد در تسک ۰۲ تکمیل می‌شود) |
`{addressUuid}` همان uuid رکورد `doctor_addresses` است. «شعبه» و «آدرس» یک چیزند.
## معیار پذیرش
- ✅ موفق: کلینیک با توکن مالک `POST /api/v1/branch` می‌زند → `201` و شعبه با
`entity_type=clinic, entity_id=<id>` ثبت می‌شود. `GET /api/v1/branches` همان را برمی‌گرداند.
- ✅ موفق: `PUT /branch/{uuid}/working-hours` با هفت روز → `200`؛ `GET /branch/{uuid}` همان
ساختار را با کلیدهای `0..6` (۰=شنبه) برمی‌گرداند.
- ❌ خطا: کلینیک B با uuid شعبهٔ کلینیک A → `404` با `ERR_NOT_FOUND_001` (نه ۴۰۳ — طبق
رفتار `TenantFilter`).
- ❌ خطا: `DELETE` شعبه‌ای که اتاق فعال دارد → `422` با پیام فارسی «شعبه دارای اتاق فعال است».
- ⚠️ مرزی: پزشک مستقل (محیط `doctor`) هم می‌تواند شعبه بسازد — «مطب» یک شعبه است.
اولین شعبه از روی `DoctorAddress` موجود ساخته می‌شود، نه دستی.
- ⚠️ مرزی: ساعت کاری با `end_time <= start_time``422`.
- ⚠️ مرزی: شعبه بدون ساعت کاری معتبر است (وراثت: تسک ۰۳ آن را «همیشه باز» تفسیر نمی‌کند،
«تعریف‌نشده» تفسیر می‌کند).
- ✅ موفق: کلینیک `PUT /branch/{uuid}/working-hours` با هفت روز می‌فرستد → `200`؛
`GET` همان ساختار را با کلیدهای `0..6` (۰=شنبه، همان قرارداد `SlotCalculatorService`)
برمی‌گرداند.
- ✅ موفق: `POST /api/v1/room` با `address_uuid` و `capacity: 3``201`، و جفت tenant
اتاق **از آدرس مشتق** می‌شود نه از بدنهٔ درخواست.
- ✅ موفق: `active` پیش‌فرض `true` و `timezone` پیش‌فرض `Asia/Tehran` — هیچ آدرس موجودی
رفتارش عوض نمی‌شود.
- ❌ خطا: آدرس محیط دیگر → `404` (رفتار `TenantFilter`، نه ۴۰۳).
- ❌ خطا: ساعت با `end_minute <= start_minute``422`.
- ❌ خطا: دو بازهٔ هم‌پوشان در یک روز`422`.
- ❌ خطا: `capacity <= 0``422`.
- ⚠️ مرزی: بازهٔ شبانه‌روزی `0..1440``200` (یک ردیف، نه دو).
- ⚠️ مرزی: روز بدون هیچ بازه → معتبر، یعنی شعبه آن روز بسته است.
- ⚠️ مرزی: شعبهٔ **بدون هیچ ساعت کاری** → «تعریف‌نشده»، نه «همیشه‌باز». تسک ۰۳ در این
حالت به رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع). این تصمیم باید در
`docs/api/branch.md` نوشته شود.
- ⚠️ مرزی: `PUT` با آرایهٔ خالی → همهٔ ساعت‌های آن شعبه پاک می‌شوند (بستنِ کامل شعبه).
## خروجی
- `src/Branch/` کامل با تست
- `assets/admin/pages/BranchesPage.tsx` + `BranchFormPage.tsx` + `RoomsPage.tsx`
- `src/Branch/` `BranchWorkingHours`، `Room`، سرویس‌ها، کنترلرها
- دو ستون روی `DoctorAddress` + migration
- `assets/admin/pages/BranchWorkingHoursPage.tsx` + `BranchRoomsPage.tsx`
- `docs/api/branch.md`
- migration + دستور `app:branch:backfill` برای ساخت شعبهٔ اولیه از آدرس‌های موجود
- [checklist.md](checklist.md) کامل‌شده