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>
122 lines
6.0 KiB
Markdown
122 lines
6.0 KiB
Markdown
# دیتابیس — تسک ۰۱
|
||
|
||
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` ثبت شود.
|