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
+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` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند.
### جدول‌های مالی