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>
352 lines
15 KiB
Markdown
352 lines
15 KiB
Markdown
# 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` (اختیاری) — انتخاب صریح محیط کلینیک.
|
||
|
||
**پاسخ ۲۰۰** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"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"}`):
|
||
|
||
```json
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منطقهٔ زمانی نامعتبر است","field":"timezone"}]}
|
||
```
|
||
|
||
**۴۰۴** — آدرسی که به محیط جاری تعلق ندارد.
|
||
|
||
---
|
||
|
||
## `GET /api/v1/branch/{addressUuid}/working-hours`
|
||
|
||
```json
|
||
{
|
||
"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 تفاضلی نیست.
|
||
|
||
```json
|
||
{
|
||
"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"`.
|
||
|
||
**پاسخ ۲۰۰** (خروجی واقعی همان بدنهٔ بالا):
|
||
|
||
```json
|
||
{
|
||
"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": []
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲ — همپوشانی** (خروجی واقعی):
|
||
|
||
```json
|
||
{"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`
|
||
|
||
```json
|
||
{
|
||
"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)`.
|
||
> پس کلاینت نمیتواند اتاقی را به محیط دیگری بچسباند.
|
||
|
||
**۲۰۱** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"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
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲ — ظرفیت صفر** (خروجی واقعی):
|
||
|
||
```json
|
||
{"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}`):
|
||
|
||
```json
|
||
{
|
||
"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` درست ردش کرد: آن ریشه خودش سراسری است، پس آن مسیر هیچ
|
||
تضمینی نمیداد. حالا جفت واقعی دارد.
|
||
|
||
---
|
||
|
||
## تستها
|
||
|
||
```bash
|
||
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
|
||
```
|