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

122 lines
6.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# دیتابیس — تسک ۰۱
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
> ⛔ **جدول `branches` ساخته نمی‌شود.** «شعبه» همان `doctor_addresses` است — دلیل کامل در
> [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). آنچه در
> نسخهٔ اول این فایل به‌عنوان جدول `branches` و ستون `doctor_addresses.branch_id` آمده بود
> حذف شد.
## تغییر جدول موجود — `doctor_addresses`
```sql
ALTER TABLE doctor_addresses
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1,
ADD COLUMN timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';
```
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. هر دو `NOT NULL DEFAULT` دارند، پس ردیف‌های موجود
بی‌نیاز از backfill درست می‌شوند و هیچ رفتار فعلی عوض نمی‌شود.
`timezone` از همین حالا اضافه می‌شود چون بند ۹ مستند ذخیره‌سازی UTC با نمایش محلی می‌خواهد؛
افزودنش بعد از اینکه داده‌های زمان‌دار روی شعبه نشستند یعنی backfill پرریسک.
⚠️ `active` در این تسک **فقط ذخیره** می‌شود؛ فیلتر شدنش در محاسبهٔ اسلات کارِ تسک ۰۳ است
(هر تغییری در `SlotCalculatorService` در فاز فعلی ممنوع است — `_shared/red-lines.md`).
## `branch_working_hours`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `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 | ۰..۱۴۴۰ از نیمه‌شب |
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
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** با ریشهٔ `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 | در سازنده از `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 | ظرفیت هم‌زمان |
| `floor` | VARCHAR(20) NULL | ویژگی آزاد — مستند بند ۶ |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
```sql
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_address (address_id, active)
```
`entity_type, entity_id` ستون‌های اولِ ایندکس‌اند — شرط `TenantFilter` وگرنه از ایندکس
استفاده نمی‌کند.
اشتقاق جفت در سازنده (نه از بدنهٔ request):
```php
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
$this->assignTenantPair(
$isClinic ? 'clinic' : 'doctor',
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
);
```
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
هیچ 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
| جدول | وضعیت | ثبت در |
|---|---|---|
| `doctor_addresses` | از قبل طبقه‌بندی‌شده — دست نمی‌خورد | همان‌جای فعلی |
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request می‌آید) |
| `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` ثبت شود.