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:
@@ -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` ثبت شود.
|
||||
|
||||
Reference in New Issue
Block a user