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>
6.0 KiB
دیتابیس — تسک ۰۱
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها INT (Unix)
⛔ جدول
branchesساخته نمیشود. «شعبه» همانdoctor_addressesاست — دلیل کامل در_shared/branch-is-doctor-address.md. آنچه در نسخهٔ اول این فایل بهعنوان جدولbranchesو ستونdoctor_addresses.branch_idآمده بود حذف شد.
تغییر جدول موجود — doctor_addresses
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 |
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 |
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_address (address_id, active)
entity_type, entity_id ستونهای اولِ ایندکساند — شرط TenantFilter وگرنه از ایندکس
استفاده نمیکند.
اشتقاق جفت در سازنده (نه از بدنهٔ request):
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
$this->assignTenantPair(
$isClinic ? 'clinic' : 'doctor',
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
);
Migration
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 قرمز میشود:
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:
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
ddev exec php bin/phpunit tests/Shared/TenantLookupInventoryTest.php
دومی هم لازم است: repository جدیدی که findByUuid دارد باید در REVIEWED ثبت شود.