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>
4.4 KiB
«شعبه» جدول تازهای نیست — doctor_addresses است
این سند بر همهٔ تسکهایی که branches یا branch_id میگویند حاکم است.
نسخهٔ اول تسک ۰۱ یک جدول branches طراحی کرده بود؛ در اجرا معلوم شد آن موجودیت از قبل
وجود دارد. تسک ۰۱ اصلاح شد و جدول ساخته نشد.
هر جا در تسکهای ۰۲، ۰۴، ۰۷، ۰۸، ۰۹، ۱۰، ۱۳ نوشته شده branch_id INT NOT NULL FK → branches(id)، بخوانید:
address_id INT NOT NULL -- FK → doctor_addresses(id)
و هر جا Branch $branch نوشته شده، بخوانید DoctorAddress $address.
چرا
App\Doctor\Entity\DoctorAddress تمام چیزی است که یک شعبه لازم دارد:
| نیاز شعبه | در DoctorAddress |
|---|---|
| نام | name |
| آدرس | address |
| تلفن | telephone |
| مختصات | latitude / longitude |
| شهر و استان | FK به entity City / Province |
| مالک (محیط) | forDoctor(Doctor) یا forClinic(int $clinicId) + ستون type |
| فعال/غیرفعال | active — تسک ۰۱ اضافه کرد |
| منطقهٔ زمانی | timezone — تسک ۰۱ اضافه کرد |
و از قبل در کل سیستم به همین معنا مصرف میشود:
WeeklySchedule.setting[day].sessions[].location_id→doctor_addresses.idSlotCalculatorService::buildSessionSlots()آن را در هر اسلات کپی میکندGET /api/v1/appointment-booking-locations/{doctorUuid}هر آدرس را «محل نوبتدهی» مینامدDoctorAddressRepository::findForContext($doctor, $clinicId)چند آدرس per محیط میدهد- ۹ endpoint CRUD موجود:
clinic/{uuid}/addresses(۴) وclinic-pro/doctor-address*(۵) - UI ادمین:
ClinicDetailPage،ClinicFormPage،DoctorDetailPage،SettingsPage
ساختن جدول موازی یعنی دو منبع حقیقت برای نام/آدرس/تلفن/مختصات یک مکان فیزیکی، و شعبهای
که location_id هرگز به آن اشاره نمیکند — یعنی decorative. قاعدهٔ #۸ پروژه:
«API/جدول جدید فقط وقتی هیچ موجودی — حتی با توسعه — کافی نباشد.»
پیامد برای تسکهای بعدی
| تسک | چه چیزی عوض میشود |
|---|---|
| ۰۲ منابع | clinic_resources.address_id → doctor_addresses(id). ResourcePool هم همین. «منابع مال شعبهاند» = مال یک آدرساند |
| ۰۳ تقویم منبع | ساعت کاری شعبه از branch_working_hours که به doctor_addresses.id کلید میخورد |
| ۰۴ کاتالوگ | service_branch_overrides.address_id |
| ۰۷ رزرو | appointments.address_id از قبل وجود دارد (Appointment::$addressId) — ستون جدید لازم نیست |
| ۰۸ قیمت | price_lists.address_id |
| ۰۹/۱۰ قوانین | policies.address_id (اختصاصیبودن per شعبه) |
| ۱۳ لغو/انتظار | waitlist_entries.address_id |
⚠️ نکتهٔ تسک ۰۷: Appointment از قبل address_id دارد (ستون addressId, تهیپذیر) و
SlotCalculatorService::resolveSlotLocationId() پرش میکند. پس آنجا هم ستون تازه لازم نیست.
جفت tenant اتاق و منابع
DoctorAddress ستونهای entity_type/entity_id ندارد؛ مالکیتش با type +
doctor_id/clinic_id بیان میشود. موجودیتهای جدیدی که به آدرس کلید میخورند و
TenantOwnedTrait دارند، جفتشان را در سازنده از آدرس مشتق میکنند:
// App\Branch\Entity\Room::__construct()
$this->assignTenantPair(
$address->getType() === DoctorAddress::TYPE_CLINIC ? 'clinic' : 'doctor',
$address->getType() === DoctorAddress::TYPE_CLINIC
? (int) $address->getClinicId()
: (int) $address->getDoctor()->getId(),
);
همان قاعدهٔ docs/architecture/tenancy.md: جفت در سازنده از ریشه مشتق میشود، نه از
ورودی درخواست — پس هیچ نقطهٔ ساختی نمیتواند فراموشش کند و write-once میماند.