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,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` ← تسک ۰۳ |