Three pages, all on the existing design system: BranchesPage lists the current environment's booking locations with their working-hours and active-room counts, and two subpages edit the week and the rooms. The list page deliberately does not create or rename a branch — clinic and doctor detail pages already do that, and duplicating it would give one physical place two edit surfaces. Route permission reuses `appointment_settings` rather than inventing a new one. Two real bugs fell out of exercising this end to end: `days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6 are sequential so json_encode collapses them to a list. The client reads days["0"] either way, so nothing looked broken, but the response shape was unstable: one missing day would flip the same field to an object. The controller now casts to stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by curling the endpoint for the docs, not by any test. `<input type="time">` caps at 23:59, so it can neither display nor produce the legal end value 1440. An all-day range would have vanished from the form and been corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a round-trip test proving 1440 survives. docs/api/branch.md documents all eight endpoints with responses captured from real curl runs against ddev, including the 422 and 404 bodies. doctor.md records that active/timezone now appear on all nine existing address endpoints (additive), and tenancy.md gains the two lessons this task taught: an aggregate child whose root is itself declared global inherits no environment and needs a real pair, and TenantFilter is not a substitute for an explicit ownership check because hard isolation only applies to a *chosen* context. Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 87 files / 612 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
Branch API — شعبه، ساعت کاری و اتاق
Base:
/api/v1· Auth: JWT روی همهٔ اندپوینتها مجوز:appointment_settings(viewبرای خواندن،updateبرای نوشتن) — همان مجوزی که تنظیمات نوبتدهی با آن سنجیده میشود. مجوز تازهای اضافه نشده.
«شعبه» جدول تازهای نیست
شعبه همان رکورد آدرس محل نوبتدهی است: doctor_addresses — همان چیزی که
WeeklySchedule.setting[day].sessions[].location_id به آن اشاره میکند و
GET /api/v1/appointment-booking-locations/{doctorUuid} آن را «محل نوبتدهی» مینامد.
پس {addressUuid} در مسیرهای زیر همان uuid رکورد آدرس است.
ساختن، ویرایش و حذف شعبه اندپوینت جدید ندارد — از قبل موجود است:
| کار | اندپوینت موجود |
|---|---|
| CRUD آدرسهای کلینیک | GET/POST/PATCH/DELETE /api/v1/clinic/{clinicUuid}/addresses |
| CRUD آدرسهای پزشک | POST/GET/PATCH/DELETE /api/v1/clinic-pro/doctor-address[/{id}] |
| آدرسهای یک پزشک | GET /api/v1/clinic-pro/doctor-addresses/{doctorId} |
این سند فقط چیزهایی را پوشش میدهد که آنجا نبودند: فهرست شعبههای محیط جاری،
دو ویژگی active/timezone، ساعت کاری هفتگی، و اتاقها.
⚠️
doctor_addressesدرGlobalTables::ENTITIESسراسری اعلام شده وTenantFilterرویش اعمال نمیشود. هر مسیری کهaddressUuidمیگیرد ازBranchResolverرد میشود که آدرس را با محیط جاری تطبیق میدهد و در غیر این صورت ۴۰۴ میدهد (نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمیرود).
دو قرارداد که باید بدانید
۱. شعبهٔ بدون ساعت کاری = «تعریفنشده»، نه «همیشهباز».
defined: false یعنی هیچ بازهای ثبت نشده. محاسبهٔ اسلات در این حالت به رفتار فعلی
برمیگردد و برنامهٔ هفتگی پزشک تنها مرجع است. پس همهٔ دادهٔ موجود — که هیچ ساعت کاری
شعبه ندارد — دقیقاً مثل قبل کار میکند.
۲. active در این فاز فقط ذخیره میشود.
غیرفعال کردن شعبه هیچ اثری بر اسلاتهای تولیدشده ندارد؛ اعمالش در تسک ۰۳ است، چون
تغییر SlotCalculatorService در فاز فعلی ممنوع است.
GET /api/v1/branches
شعبههای محیط جاری. برای منشی، محیط از رابطهٔ فعال او حل میشود؛ برای بقیه از
clinic_uuid درخواست، بعد محیط فعال، بعد نقش.
Query: clinic_uuid (اختیاری) — انتخاب صریح محیط کلینیک.
پاسخ ۲۰۰ (خروجی واقعی):
{
"success": true,
"data": [
{
"id": "11547",
"uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"type": "clinic",
"clinic_id": 4,
"clinic_name": null,
"name": "درمانگاه شبانه روزی صدرا ",
"map": { "latitude": "30.667110344662", "longitude": "51.597043275833" },
"address": "خیابا پزشک روبه روی لوازم خانگی هرمزی ",
"telephone": "07433221212",
"active": true,
"timezone": "Asia/Tehran",
"city": { "id": "123", "name": "یاسوج" },
"province": { "id": "23", "name": "کهگیلویه و بویراحمد" },
"working_hours_defined": false,
"rooms_count": 0
}
]
}
working_hours_defined و rooms_count فقط در این اندپوینت هستند و با دو کوئری
گروهی پر میشوند، نه دو کوئری per شعبه — BranchFieldsTest::testListQueryCountDoesNotGrowWithBranches
همین را قفل میکند. rooms_count فقط اتاق فعال را میشمارد.
active و timezone روی خروجی همهٔ ۹ اندپوینت موجود آدرس هم ظاهر میشوند، چون از
DoctorAddress::toArray() میآیند. تغییر additive است و هیچ فیلدی حذف نشده.
PATCH /api/v1/branch/{addressUuid}
فقط دو ویژگی شعبهای. نام/آدرس/تلفن/مختصات همانجایی ویرایش میشوند که همیشه.
| فیلد | نوع | توضیح |
|---|---|---|
active |
bool | اختیاری |
timezone |
string | اختیاری — با DateTimeZone::listIdentifiers() سنجیده میشود، نه regex |
۲۰۰ بدنهٔ کامل شعبه را برمیگرداند (همان شکل بالا).
۴۲۲ — منطقهٔ زمانی ناشناخته (خروجی واقعی برای {"timezone":"Tehran"}):
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منطقهٔ زمانی نامعتبر است","field":"timezone"}]}
۴۰۴ — آدرسی که به محیط جاری تعلق ندارد.
GET /api/v1/branch/{addressUuid}/working-hours
{
"success": true,
"data": {
"branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"timezone": "Asia/Tehran",
"defined": false,
"days": { "0": [], "1": [], "2": [], "3": [], "4": [], "5": [], "6": [] }
}
}
days همیشه شیء با هر هفت کلید "0".."6" است — ۰ = شنبه، همان قرارداد
SlotCalculatorService. روزِ خالی یعنی شعبه آن روز بسته است.
کلیدهای ۰..۶ پشتسرهماند، پس
json_encodeبیمراقبت آرایهٔ PHP را به آرایهٔ JSON تبدیل میکرد. کنترلر عمداً بهstdClassتبدیل میکند وWorkingHoursTest::testDaysIsAJsonObjectNotAnArrayشکل را قفل میکند.
PUT /api/v1/branch/{addressUuid}/working-hours
جایگزینی کامل هفت روز. بدنه تمام حقیقت است: روزی که نفرستید خالی میشود و
{"days":{}} همهٔ ساعتهای شعبه را پاک میکند (بستن کامل شعبه). merge تفاضلی نیست.
{
"days": {
"0": [
{ "start_minute": 540, "end_minute": 780 },
{ "start_minute": 960, "end_minute": 1200 }
],
"1": [{ "start_minute": 540, "end_minute": 780 }]
}
}
| فیلد | نوع | قاعده |
|---|---|---|
| کلید روز | "0".."6" |
۰ = شنبه |
start_minute |
int | دقیقه از نیمهشب، 0..1440 |
end_minute |
int | 0..1440 و اکیداً بزرگتر از start_minute |
sequence را کلاینت نمیفرستد؛ سرور بعد از مرتبسازی بازهها تخصیص میدهد.
زمانها عددیاند نه رشتهٔ "09:00"، چون تقاطع دو بازه محاسبهٔ عددی است و مقایسهٔ
رشتهای "9:00" < "10:00" غلط جواب میدهد. start_time/end_time در پاسخ فقط برای
نمایشاند. بازهٔ شبانهروزی 0..1440 یک ردیف است و end_time آن "24:00" میشود،
نه "00:00".
پاسخ ۲۰۰ (خروجی واقعی همان بدنهٔ بالا):
{
"success": true,
"data": {
"branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"timezone": "Asia/Tehran",
"defined": true,
"days": {
"0": [
{ "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true },
{ "sequence": 1, "start_minute": 960, "end_minute": 1200, "start_time": "16:00", "end_time": "20:00", "active": true }
],
"1": [
{ "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true }
],
"2": [], "3": [], "4": [], "5": [], "6": []
}
}
}
۴۲۲ — همپوشانی (خروجی واقعی):
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازههای روز 2 با هم همپوشانی دارند","field":"start_minute"}]}
سایر ۴۲۲ها: end_minute <= start_minute (field end_minute) · دقیقهٔ بیرون از
0..1440 · کلید روز بیرون از 0..6 (field day_of_week) · نبودِ days (field days).
بازهٔ چسبیده خطا نیست: 13:00–15:00 بعد از 09:00–13:00 مجاز است.
اتمی است. اعتبارسنجی کاملِ هر هفت روز پیش از هر
DELETEاجرا میشود، پس یک بازهٔ نامعتبر در روز ششم، شش روز درستِ قبلی را پاک نمیکند و بعد ۴۲۲ برگرداند (WorkingHoursTest::testInvalidLaterDayLeavesTheStoredWeekUntouched).
GET /api/v1/branch/{addressUuid}/rooms
{
"success": true,
"data": [
{
"uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"address_name": "درمانگاه شبانه روزی صدرا ",
"name": "اتاق تزریقات",
"room_type": "تزریقات",
"capacity": 3,
"floor": "۱",
"active": true,
"created_at": 1785416929,
"updated_at": 1785416929
}
]
}
هم فعال و هم غیرفعال برمیگردد؛ فیلتر در UI است.
POST /api/v1/room
| فیلد | نوع | الزامی | توضیح |
|---|---|---|---|
address_uuid |
string | ✅ | شعبهای که اتاق در آن است |
name |
string | ✅ | حداکثر ۱۲۰ نویسه |
room_type |
string|null | — | متن آزاد؛ نوع اتاق را کلینیک تعریف میکند |
capacity |
int | — | پیشفرض ۱، حداقل ۱ |
floor |
string|null | — | حداکثر ۲۰ نویسه |
active |
bool | — | پیشفرض true |
capacity تعداد بیمار همزمان است. اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است،
نه سه اتاق (بند ۶ مستند طراحی).
جفت محیط اتاق در سازندهٔ entity از خودِ آدرس مشتق میشود، نه از بدنهٔ درخواست: آدرس
type=clinic⇒(clinic, clinic_id)وtype=personal⇒(doctor, doctor_id). پس کلاینت نمیتواند اتاقی را به محیط دیگری بچسباند.
۲۰۱ (خروجی واقعی):
{
"success": true,
"data": {
"uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"address_name": "درمانگاه شبانه روزی صدرا ",
"name": "اتاق تزریقات",
"room_type": "تزریقات",
"capacity": 3,
"floor": "۱",
"active": true,
"created_at": 1785416929,
"updated_at": 1785416929
}
}
۴۲۲ — ظرفیت صفر (خروجی واقعی):
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"ظرفیت اتاق حداقل ۱ است","field":"capacity"}]}
سایر ۴۲۲ها: address_uuid نبود (field address_uuid) · نام خالی یا فقط فاصله
(field name، کد ERR_VALIDATION_002).
۴۰۴ — آدرس متعلق به محیط جاری نیست.
PATCH /api/v1/room/{uuid}
همان فیلدهای POST منهای address_uuid — اتاق بین شعبهها جابهجا نمیشود (جفت محیطش
از آدرس مشتق شده و write-once است). فیلدِ نفرستاده دستنخورده میماند؛ رشتهٔ خالی روی
room_type/floor یعنی «پاک کن» و null ذخیره میشود.
۲۰۰ (خروجی واقعی برای {"capacity":2,"active":false}):
{
"success": true,
"data": {
"uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"address_name": "درمانگاه شبانه روزی صدرا ",
"name": "اتاق تزریقات",
"room_type": "تزریقات",
"capacity": 2,
"floor": "۱",
"active": false,
"created_at": 1785416929,
"updated_at": 1785416942
}
}
۴۰۴ — اتاق محیط دیگر.
مالکیت صریح سنجیده میشود و به
TenantFilterتکیه نمیشود: جداسازی سختِ فیلتر فقط روی محیطِ انتخابشده اعمال میشود، پس پزشکی که هنوز محیطی برنگزیده بود میتوانست اتاق کلینیک دیگری را PATCH کند. باRoomCrudTest::testForeignRoomIsNotFoundگرفته و بسته شد.
DELETE /api/v1/room/{uuid}
۲۰۰ (خروجی واقعی): {"success":true,"data":null}
۴۰۴ — اتاق محیط دیگر.
در این فاز حذف اتاق قید ندارد، چون اتاق هنوز وابستهٔ زندهای ندارد. دلایل منع حذف
از راه RoomDeletionGuardInterface تزریق میشوند: تسک ۰۲ (منبع فعال روی اتاق) و تسک ۰۷
(نوبت آیندهٔ آن منابع) هرکدام یک پیادهسازی اضافه میکنند و RoomService دست نمیخورد.
مسیر اصلیِ «کنار گذاشتن» اتاق active=false است، نه DELETE.
⚠️ حذف آدرس ساعتهای کاری و اتاقهایش را با ON DELETE CASCADE میبرد. تا وقتی
نوبت به اتاق وصل نشده (تسک ۰۷) بیخطر است؛ آنجا باید گاردِ حذف آدرس اضافه شود.
طبقهبندی محیط
| جدول | وضعیت |
|---|---|
doctor_addresses |
GlobalTables::ENTITIES — سراسری، محافظش BranchResolver |
branch_working_hours |
جفت (entity_type, entity_id) مشتق از آدرس در سازنده |
rooms |
جفت (entity_type, entity_id) مشتق از آدرس در سازنده |
branch_working_hours اول بهعنوان فرزند aggregate با ریشهٔ DoctorAddress ثبت شد و
TenantSchemaCoverageTest درست ردش کرد: آن ریشه خودش سراسری است، پس آن مسیر هیچ
تضمینی نمیداد. حالا جفت واقعی دارد.
تستها
ddev exec php bin/phpunit tests/Branch # ۳۶ تست / ۱۰۱ assertion
ddev exec php bin/phpunit --group=slot-mode-frozen # منطق اسلاتی دستنخورده
npx vitest run assets/admin/pages/BranchWorkingHoursPage.test.tsx