# 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 ```