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
@@ -0,0 +1,82 @@
# «شعبه» جدول تازه‌ای نیست — `doctor_addresses` است
**این سند بر همهٔ تسک‌هایی که `branches` یا `branch_id` می‌گویند حاکم است.**
نسخهٔ اول تسک ۰۱ یک جدول `branches` طراحی کرده بود؛ در اجرا معلوم شد آن موجودیت از قبل
وجود دارد. تسک ۰۱ اصلاح شد و جدول ساخته **نشد**.
هر جا در تسک‌های ۰۲، ۰۴، ۰۷، ۰۸، ۰۹، ۱۰، ۱۳ نوشته شده `branch_id INT NOT NULL FK →
branches(id)`، بخوانید:
```sql
address_id INT NOT NULL -- FK → doctor_addresses(id)
```
و هر جا `Branch $branch` نوشته شده، بخوانید `DoctorAddress $address`.
---
## چرا
`App\Doctor\Entity\DoctorAddress` تمام چیزی است که یک شعبه لازم دارد:
| نیاز شعبه | در `DoctorAddress` |
|---|---|
| نام | `name` |
| آدرس | `address` |
| تلفن | `telephone` |
| مختصات | `latitude` / `longitude` |
| شهر و استان | FK به entity `City` / `Province` |
| مالک (محیط) | `forDoctor(Doctor)` یا `forClinic(int $clinicId)` + ستون `type` |
| فعال/غیرفعال | `active`**تسک ۰۱ اضافه کرد** |
| منطقهٔ زمانی | `timezone`**تسک ۰۱ اضافه کرد** |
و از قبل در کل سیستم به همین معنا مصرف می‌شود:
- `WeeklySchedule.setting[day].sessions[].location_id``doctor_addresses.id`
- `SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند
- `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`
ساختن جدول موازی یعنی دو منبع حقیقت برای نام/آدرس/تلفن/مختصات یک مکان فیزیکی، و شعبه‌ای
که `location_id` هرگز به آن اشاره نمی‌کند — یعنی decorative. قاعدهٔ پروژه:
«API/جدول جدید فقط وقتی هیچ موجودی — حتی با توسعه — کافی نباشد.»
---
## پیامد برای تسک‌های بعدی
| تسک | چه چیزی عوض می‌شود |
|---|---|
| ۰۲ منابع | `clinic_resources.address_id``doctor_addresses(id)`. `ResourcePool` هم همین. «منابع مال شعبه‌اند» = مال یک آدرس‌اند |
| ۰۳ تقویم منبع | ساعت کاری شعبه از `branch_working_hours` که به `doctor_addresses.id` کلید می‌خورد |
| ۰۴ کاتالوگ | `service_branch_overrides.address_id` |
| ۰۷ رزرو | `appointments.address_id` **از قبل وجود دارد** (`Appointment::$addressId`) — ستون جدید لازم نیست |
| ۰۸ قیمت | `price_lists.address_id` |
| ۰۹/۱۰ قوانین | `policies.address_id` (اختصاصی‌بودن per شعبه) |
| ۱۳ لغو/انتظار | `waitlist_entries.address_id` |
⚠️ نکتهٔ تسک ۰۷: `Appointment` از قبل `address_id` دارد (ستون `addressId`, تهی‌پذیر) و
`SlotCalculatorService::resolveSlotLocationId()` پرش می‌کند. پس آنجا هم ستون تازه لازم نیست.
---
## جفت tenant اتاق و منابع
`DoctorAddress` ستون‌های `entity_type`/`entity_id` ندارد؛ مالکیتش با `type` +
`doctor_id`/`clinic_id` بیان می‌شود. موجودیت‌های جدیدی که به آدرس کلید می‌خورند و
`TenantOwnedTrait` دارند، جفتشان را در **سازنده از آدرس مشتق** می‌کنند:
```php
// App\Branch\Entity\Room::__construct()
$this->assignTenantPair(
$address->getType() === DoctorAddress::TYPE_CLINIC ? 'clinic' : 'doctor',
$address->getType() === DoctorAddress::TYPE_CLINIC
? (int) $address->getClinicId()
: (int) $address->getDoctor()->getId(),
);
```
همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق می‌شود، نه از
ورودی درخواست — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند و write-once می‌ماند.