Files
clinicpro/docs/new_feture/taskes/task-01-branch-room/database.md
T
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

6.0 KiB
Raw Blame History

دیتابیس — تسک ۰۱

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 ثبت شود.