Files
clinicpro/docs/new_feture/taskes/_shared/branch-is-doctor-address.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

4.4 KiB
Raw Blame History

«شعبه» جدول تازه‌ای نیست — 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_iddoctor_addresses.id
  • SlotCalculatorService::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_iddoctor_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 می‌ماند.