Files
clinicpro/docs/api/branch.md
T
hamedandClaude Opus 5 d813843fcd feat(branch): admin UI for branch working hours and rooms, plus real API docs
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>
2026-07-30 16:48:49 +03:30

15 KiB
Raw Blame History

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:0015:00 بعد از 09:0013: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