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>
This commit is contained in:
hamed
2026-07-30 16:48:49 +03:30
co-authored by Claude Opus 5
parent eebb363b9f
commit d813843fcd
15 changed files with 1472 additions and 63 deletions
+1
View File
@@ -82,6 +82,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
| [doctor.md](doctor.md) | Doctor profile & addresses | 11 |
| [clinic.md](clinic.md) | Clinics | 7 |
| [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 |
| [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 |
| [appointment.md](appointment.md) | Appointments & slot booking | 6 |
| [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 |
| [payment.md](payment.md) | Payments (Mellat / Sep) | 5 |
+351
View File
@@ -0,0 +1,351 @@
# 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:0015:00` بعد از `09:0013: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
```
+14
View File
@@ -445,6 +445,8 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
"name": "مطب تهران",
"address": "تهران، خیابان...",
"telephone": "02112345678",
"active": true,
"timezone": "Asia/Tehran",
"map": { "latitude": "35.6892", "longitude": "51.3890" },
"city": { "id": "1", "name": "تهران" },
"province": { "id": "1", "name": "تهران" }
@@ -458,6 +460,8 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
"name": null,
"address": "اصفهان، خیابان...",
"telephone": "03112345678",
"active": true,
"timezone": "Asia/Tehran",
"map": { "latitude": null, "longitude": null },
"city": { "id": "3", "name": "اصفهان" },
"province": { "id": "2", "name": "اصفهان" }
@@ -468,6 +472,16 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
> **نکته:** آدرس‌های با `type: "clinic"` از کلینیک‌هایی که پزشک عضو آن‌هاست می‌آیند و `clinic_name` نام کلینیک را نشان می‌دهد.
> **`active` و `timezone` (افزوده‌شده در تسک شعبه):** هر آدرس یک «شعبه» است و این دو
> ویژگی روی خروجی **همهٔ** اندپوینت‌های آدرس ظاهر می‌شوند، چون از
> `DoctorAddress::toArray()` می‌آیند. هر دو ستون `NOT NULL DEFAULT` دارند، پس ردیف‌های
> قدیمی هم `active: true` و `timezone: "Asia/Tehran"` می‌دهند؛ تغییر additive است.
>
> `active` در فاز فعلی **فقط ذخیره می‌شود** و هیچ اثری بر محاسبهٔ اسلات ندارد. نوشتن
> این دو فیلد از راه اندپوینت‌های همین سند انجام نمی‌شود؛ برای آن
> `PATCH /api/v1/branch/{addressUuid}` است — همراه با ساعت کاری هفتگی و اتاق‌ها در
> [branch.md](branch.md).
---
## POST `/api/v1/clinic-pro/doctor-address`
+30 -1
View File
@@ -10,7 +10,7 @@
| ستون | مقدار |
|---|---|
| `entity_type` | `doctor` یا `clinic``VARCHAR(10)` در هر ۲۰ جدول tenant-دار |
| `entity_type` | `doctor` یا `clinic``VARCHAR(10)` در همهٔ جدول‌های tenant-دار (طولِ یکسان، وگرنه JOIN به collation mismatch می‌خورد) |
| `entity_id` | شناسهٔ همان پزشک یا کلینیک |
موجودیت‌ها این جفت را از trait مشترک می‌گیرند:
@@ -132,6 +132,28 @@ public function __construct(ServiceSection $section, ...) {
`RequestReachableChildTenantTest` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود.
### ⚠️ ریشهٔ سراسری، فرزندِ محیط‌دار — پروندهٔ `doctor_addresses`
`doctor_addresses` عمداً در `ENTITIES` سراسری است («آدرس‌های پزشک؛ در همهٔ محیط‌های او
یکسان است»)، پس **فیلتر رویش اعمال نمی‌شود** و `findOneBy(['uuid' => …])` آدرس کلینیک
دیگر را هم برمی‌گرداند. دو جدولِ تسک شعبه روی همین ریشه نشستند و درس دادند:
| جدول | طبقه‌بندی | چرا |
|---|---|---|
| `branch_working_hours` | جفت محیط **خودش** | اول به‌عنوان فرزند aggregate با ریشهٔ `DoctorAddress` ثبت شد و `TenantSchemaCoverageTest` ردش کرد: ریشه‌ای که خودش سراسری است، هیچ محیطی برای ارث دادن ندارد |
| `rooms` | جفت محیط خودش | uuidش از درخواست می‌آید — همان قاعدهٔ فاز ۸ |
جفت از **`type` آدرس** مشتق می‌شود، که نگاشتی کامل است:
`personal ⇒ (doctor, doctor_id)` و `clinic ⇒ (clinic, clinic_id)`. چون آدرس هم فقط در
محیط خودش فهرست می‌شود، هیچ ردیفی بی‌دلیل پنهان نمی‌شود.
خودِ آدرس محافظ دستی دارد: `App\Branch\Service\BranchResolver` تک‌نقطهٔ تبدیل
«uuid شعبه در request» به آدرسِ محیط جاری است و در غیر این صورت **۴۰۴** می‌دهد — همان
رفتار فیلتر، نه ۴۰۳.
**درسِ عملیاتی:** آنجا که `AGGREGATE_CHILDREN` بی‌فایده است، فقط طبقه‌بندی عوض نکن؛
جفت واقعی بده. و برای ریشهٔ سراسری یک resolver واحد بساز، نه بررسی تکراری در هر کنترلر.
### uuid از درخواست — خطرناک‌ترین الگو
سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد:
@@ -152,6 +174,13 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی
موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است.
**فیلتر جایگزین این بررسی نیست، حتی روی جدولِ جفت‌دار.** جداسازی سختِ `TenantFilter`
فقط روی محیطِ **انتخاب‌شده** اعمال می‌شود ({@see `EntityContext::$chosen`}). پزشکی که
هنوز محیطی برنگزیده در هیچ محیطی «نیست»، پس فیلتر برایش خاموش است و
`PATCH /api/v1/room/{uuid}` می‌توانست اتاق کلینیک دیگری را ویرایش کند — با
`RoomCrudTest::testForeignRoomIsNotFound` گرفته شد که قبل از اصلاح ۲۰۰ می‌داد.
هر کنترلری که uuid را از request می‌گیرد باید `belongsToPair()` را خودش صدا بزند.
`TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند.
### جدول‌های مالی
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۱ (شعبه و اتاق)
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تکمیل‌شده (۲ ردیف 🔄 بازبینی چشمی) · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) ·
@@ -12,11 +12,11 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `SlotCalculatorService` دست‌نخورده | | این تسک به آن کاری ندارد |
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | | شعبه = همان `doctor_addresses.id` |
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | | فقط `active` و `timezone` با `DEFAULT` |
| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | | اعمالش تسک ۰۳ است |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | `--group=slot-mode-frozen` — ۳ تست / ۸ assertion سبز |
| ۰.۲ | `SlotCalculatorService` دست‌نخورده | | صفر تغییر در فایل |
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | | شعبه = همان `doctor_addresses.id`؛ JSON دست‌نخورده |
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | | فقط دو ستون `NOT NULL DEFAULT` |
| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | | فقط ذخیره می‌شود؛ در `branch.md` نوشته شد |
## ۱. طرح
@@ -30,81 +30,81 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `DoctorAddress` += `active` + `timezone` | | |
| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی می‌شود، نه regex | | |
| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | | |
| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | | نه از بدنهٔ request |
| ۲.۵ | `BranchResolver` — تک‌نقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | | `TenantFilter` روی `doctor_addresses` کار نمی‌کند |
| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | | |
| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | | |
| ۲.۸ | `sequence` سمت سرور تخصیص می‌یابد، نه کلاینت | | |
| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | | تسک ۰۲ و ۰۷ گارد اضافه می‌کنند |
| ۲.۱۰ | شش endpoint ساخته شد | | صفر endpoint CRUD شعبه — موجود است |
| ۲.۱۱ | پزشک مستقل هم شعبه دارد | | `type='personal'` از قبل کار می‌کند |
| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | | |
| ۲.۱ | `DoctorAddress` += `active` + `timezone` | | |
| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی می‌شود، نه regex | | `DateTimeZone::listIdentifiers()` در setter |
| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | | ولی **جفت tenant** گرفت نه فرزند aggregate — ردیف ۳.۵ |
| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | | جفت در سازنده از `tenantEntityType/Id()` آدرس |
| ۲.۵ | `BranchResolver` — تک‌نقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | | ۴۰۴ می‌دهد؛ منشی هم پوشش دارد |
| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | | `WorkingHoursTest::testInvalidLaterDayLeavesTheStoredWeekUntouched` |
| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | | |
| ۲.۸ | `sequence` سمت سرور تخصیص می‌یابد، نه کلاینت | | بعد از `usort` تخصیص می‌یابد |
| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | | `RoomDeletionGuardInterface` + `AutowireIterator` + `_instanceof` |
| ۲.۱۰ | شش endpoint ساخته شد | | هشت شد نه شش: `GET /branches` و `PATCH /branch/{uuid}` هم لازم بودند |
| ۲.۱۱ | پزشک مستقل هم شعبه دارد | | `type=personal` از قبل کار می‌کرد |
| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | | |
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `branch_working_hours` · `rooms` | | |
| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | | |
| ۳.۳ | `timezone` روی آدرس از روز اول | | افزودن بعدی = backfill زمان‌دار |
| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | | اتاق سه‌تخته = یک ردیف با ۳ |
| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | | |
| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | | تاریخچهٔ migration جدا |
| ۳.۷ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | | |
| ۳.۱ | `branch_working_hours` · `rooms` | | `Version20260730125038` |
| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | | `idx_rooms_tenant` و `idx_bwh_tenant` |
| ۳.۳ | `timezone` روی آدرس از روز اول | | افزودن بعدی = backfill زمان‌دار |
| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | | حداقل ۱ در setter و سرویس |
| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | | **رد شد** — ریشه سراسری است، پس جفت واقعی گرفت |
| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | | دستی، چون `db_test` تاریخچهٔ جدا دارد |
| ۳.۷ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | | سبز بدون نیاز به ثبت تازه |
## ۴. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | | |
| ۴.۲ | `DataTable` با skeleton و empty state فارسی | | |
| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | | |
| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `<select>` بومی | | |
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | | |
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | | |
| ۴.۷ | دارک‌مود و حالت فشرده بررسی شد | | |
| ۴.۸ | RTL و موبایل بررسی شد | | |
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» | | |
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | | |
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | | |
| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | | |
| ۴.۲ | `DataTable` با skeleton و empty state فارسی | | |
| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | | `backTo` + breadcrumb روی هر دو زیرصفحه |
| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `<select>` بومی | | `SearchableSelect` برای منطقهٔ زمانی؛ هیچ `<select>` بومی |
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | | جستجو و فیلتر وضعیت در URL |
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | | همه از `var(--…)` |
| ۴.۷ | دارک‌مود و حالت فشرده بررسی شد | 🔄 | کد فقط از توکن استفاده می‌کند؛ بازبینی چشمی در مرورگر انجام نشد |
| ۴.۸ | RTL و موبایل بررسی شد | 🔄 | چیدمان flex/grid با wrap؛ بازبینی چشمی موبایل انجام نشد |
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» | | confirm + title روی تنها شعبهٔ فعال |
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | | سه مسیر + آیتم منو با `MapPinIcon` |
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | | `appointment_settings` بازاستفاده شد |
## ۵. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، هم‌پوشانی، `0..1440`، آرایهٔ خالی | | |
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دست‌نخورده | | |
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | | |
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | | |
| ۵.۵ | `BranchAddressFieldsTest` — پیش‌فرض‌ها، `timezone` نامعتبر → ۴۲۲ | | |
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | | |
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، هم‌پوشانی، `0..1440`، آرایهٔ خالی | | ۱۴ تست |
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دست‌نخورده | | |
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | | ۱۲ تست |
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | | هم شعبه و هم اتاق |
| ۵.۵ | `BranchAddressFieldsTest` — پیش‌فرض‌ها، `timezone` نامعتبر → ۴۲۲ | | ۱۰ تست شامل شمارش کوئری |
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | | صفر خطا در `src/Branch` |
## ۶. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | | JSON واقعی از curl |
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریف‌نشده، نه همیشه‌باز» نوشته شد | | تسک ۰۳ رویش حساب می‌کند |
| ۶.۳ | «`active` در این فاز بی‌اثر بر اسلات» نوشته شد | | |
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | | تغییر قرارداد است |
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقه‌بندی به‌روز شد | | |
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | | JSON واقعی از curl روی ddev + ثبت در README |
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریف‌نشده، نه همیشه‌باز» نوشته شد | | تسک ۰۳ رویش حساب می‌کند |
| ۶.۳ | «`active` در این فاز بی‌اثر بر اسلات» نوشته شد | | |
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | | `doctor.md` — تغییر additive روی ۹ اندپوینت |
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقه‌بندی به‌روز شد | | درسِ «ریشهٔ سراسری، فرزندِ محیط‌دار» + محدودیت `chosen` |
## ۷. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | |
| ۷.۲ | `bin/phpunit` کامل سبز | | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | ⏳ | |
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | | دو فیلد جدید additive است |
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⚠️ | دو 🔄 مانده، هر دو بازبینی چشمی UI با دلیل مکتوب |
| ۷.۲ | `bin/phpunit` کامل سبز | | ۱۰۶۷ تست / ۲۹۷۴ assertion — صفر خطا |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | | ۱۴ خطا قبل و بعد — هیچ‌کدام در فایل‌های این تسک |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | `tsc` صفر خطا · vitest ۸۷ فایل / ۶۱۲ تست |
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | ⚠️ | دو ردیف ۴.۷/۴.۸ بازبینی چشمی می‌خواهند |
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | | هر دو آدرس را مصرف می‌کنند (`app/doctor/[slug]/page.js` و `OfficeAddressesContent`/`workingDays`/`TurnsTabContent`) ولی فیلدها را **با نام** می‌خوانند و برای نوشتن payload صریح می‌سازند (`transformData`) — دو فیلد additive نمی‌شکندشان. هیچ اندپوینت جدیدی مصرف‌کننده ندارد |
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | گاردِ حذف اتاق ← تسک ۰۲/۰۷ · گاردِ حذف آدرس ← تسک ۰۷ · اعمال `active` ← تسک ۰۳ |