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
@@ -2,65 +2,62 @@
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
## `branches`
> ⛔ **جدول `branches` ساخته نمی‌شود.** «شعبه» همان `doctor_addresses` است — دلیل کامل در
> [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). آنچه در
> نسخهٔ اول این فایل به‌عنوان جدول `branches` و ستون `doctor_addresses.branch_id` آمده بود
> حذف شد.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | ارجاع خارجی |
| `entity_type` | VARCHAR(10) NOT NULL | `doctor` \| `clinic` |
| `entity_id` | INT NOT NULL | |
| `name` | VARCHAR(150) NOT NULL | |
| `phone` | VARCHAR(20) NULL | |
| `address` | TEXT NULL | |
| `city_id` | INT NULL | FK منطقی به `categories.id` با `bundle='city'` |
| `province_id` | INT NULL | همان الگو با `bundle='state'` |
| `latitude` | DOUBLE NULL | |
| `longitude` | DOUBLE NULL | |
| `timezone` | VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran' | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
## تغییر جدول موجود — `doctor_addresses`
ایندکس‌ها:
```sql
KEY idx_branches_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_branches_uuid (uuid)
ALTER TABLE doctor_addresses
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1,
ADD COLUMN timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';
```
> `entity_type, entity_id` ستون‌های اول‌اند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمی‌کند.
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. هر دو `NOT NULL DEFAULT` دارند، پس ردیف‌های موجود
بی‌نیاز از backfill درست می‌شوند و هیچ رفتار فعلی عوض نمی‌شود.
`timezone` از روز اول هست چون مستند بند ۹ می‌گوید ذخیره‌سازی UTC و نمایش محلی؛ امروز همه‌جا
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی داده‌های زمان‌دار.
`timezone` از همین حالا اضافه می‌شود چون بند ۹ مستند ذخیره‌سازی UTC با نمایش محلی می‌خواهد؛
افزودنش بعد از اینکه داده‌های زمان‌دار روی شعبه نشستند یعنی backfill پرریسک.
⚠️ `active` در این تسک **فقط ذخیره** می‌شود؛ فیلتر شدنش در محاسبهٔ اسلات کارِ تسک ۰۳ است
(هر تغییری در `SlotCalculatorService` در فاز فعلی ممنوع است — `_shared/red-lines.md`).
## `branch_working_hours`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه — همان قرارداد `SlotCalculatorService` |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ |
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ از نیمه‌شب |
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
UNIQUE KEY uniq_branch_day_seq (branch_id, day_of_week, sequence)
KEY idx_bwh_branch_day (branch_id, day_of_week, active)
UNIQUE KEY uniq_bwh_address_day_seq (address_id, day_of_week, sequence)
KEY idx_bwh_address_day (address_id, day_of_week, active)
```
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمی‌گیرد
(همیشه از راه `/branch/{uuid}/working-hours` لود می‌شود). در `GlobalTables::AGGREGATE_CHILDREN`
با ریشهٔ صریح ثبت شود.
بدون ستون tenant — **فرزند aggregate** با ریشهٔ `DoctorAddress`. هرگز با uuid از request
لود نمی‌شود؛ تنها راه رسیدن به آن `/branch/{addressUuid}/working-hours` است که آدرس را از
`TenantFilter` رد می‌کند. در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ثبت می‌شود.
دقت: چون `doctor_addresses` خودش `TenantOwnedTrait` ندارد (مالکیتش با `type` +
`doctor_id`/`clinic_id` است)، `TenantFilter` روی خودِ آدرس هم اعمال نمی‌شود.
پس فیلتر محیط برای این endpoint **دستی** است: `DoctorAddressRepository::findForContext()`
که از قبل همین کار را می‌کند و در `clinic/{uuid}/addresses` هم همین‌طور استفاده شده.
## `rooms`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → پس جفت tenant خودش را دارد |
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | از `branch` در سازنده مشتق می‌شود |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → پس جفت tenant لازم دارد |
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | در سازنده از `DoctorAddress` مشتق می‌شود |
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
| `name` | VARCHAR(120) NOT NULL | |
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
@@ -70,39 +67,55 @@ KEY idx_bwh_branch_day (branch_id, day_of_week, active)
```sql
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_branch (branch_id, active)
KEY idx_rooms_address (address_id, active)
```
## تغییر جدول موجود
`entity_type, entity_id` ستون‌های اولِ ایندکس‌اند — شرط `TenantFilter` وگرنه از ایندکس
استفاده نمی‌کند.
```sql
ALTER TABLE doctor_addresses
ADD COLUMN branch_id INT NULL,
ADD CONSTRAINT fk_doctor_addresses_branch
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
ADD KEY idx_doctor_addresses_branch (branch_id);
اشتقاق جفت در سازنده (نه از بدنهٔ request):
```php
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
$this->assignTenantPair(
$isClinic ? 'clinic' : 'doctor',
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
);
```
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. `location_id` در JSON برنامهٔ هفتگی دست‌نخورده می‌ماند.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:branch:backfill # dry-run
ddev exec php bin/console app:branch:backfill --force
```
هیچ command backfill لازم نیست — `app:branch:backfill` نسخهٔ اول برای پر کردن `branch_id`
از `doctor_addresses` بود؛ حالا که جدول موازی ساخته نمی‌شود، موضوعش منتفی است.
⚠️ **`db_test` تاریخچهٔ migration جدا دارد** و `migrate` رویش با
`Table 'users' already exists` می‌شکند. ستون‌ها را دستی اضافه کن وگرنه کل تست‌سوئیت با
`Unknown column` قرمز می‌شود:
```bash
ddev mysql -uroot -proot -e "ALTER TABLE db_test.doctor_addresses \
ADD active TINYINT(1) NOT NULL DEFAULT 1, \
ADD timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';"
```
و بعد از تولید migration، همان `CREATE TABLE` های `branch_working_hours` و `rooms` را هم
روی `db_test` اجرا کن.
## طبقه‌بندی tenant
| جدول | وضعیت | ثبت در |
|---|---|---|
| `branches` | جفت tenant | `TenantOwnedTrait` |
| `doctor_addresses` | از قبل طبقه‌بندی‌شده — دست نمی‌خورد | همان‌جای فعلی |
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request می‌آید) |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `DoctorAddress` |
بعد از migration:
```bash
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
ddev exec php bin/phpunit tests/Shared/TenantLookupInventoryTest.php
```
دومی هم لازم است: repository جدیدی که `findByUuid` دارد باید در `REVIEWED` ثبت شود.