Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.3 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 میماند.
بهروزرسانی پس از تسک ۰۲
منابع ساخته شدند و clinic_resources.address_id و resource_pools.address_id هر دو
به doctor_addresses(id) میخورند — همانطور که جدول بالا پیشبینی کرده بود.
یک تصحیح اضافه روی همان جدول: کلید یکتای منبعِ پزشک
(doctor_id, address_id) است نه (doctor_id). یک WeeklySchedule per جفت
(پزشک، کلینیک) است ولی هر شیفتِ درونش location_id خودش را دارد، پس یک پزشک از قبل
در چند آدرسِ یک محیط کار میکند. همین برای (staff_id, address_id) هم صادق است.
rooms استثناست: اتاق ذاتاً در یک آدرس است، پس UNIQUE(room_id) کافی است.
تسکهای ۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ که هنوز branch_id میگویند، همین الگو را دنبال کنند.