feat(resource): admin UI for resources, types, skills and pools, plus real API docs

Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.

The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.

Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.

docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.

Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".

Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 17:51:38 +03:30
co-authored by Claude Opus 5
parent 964c09cc00
commit 04d3222559
19 changed files with 2028 additions and 66 deletions
@@ -80,3 +80,18 @@ $this->assignTenantPair(
همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق می‌شود، نه از
ورودی درخواست — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند و write-once می‌ماند.
---
## به‌روزرسانی پس از تسک ۰۲
منابع ساخته شدند و `clinic_resources.address_id` و `resource_pools.address_id` هر دو
به `doctor_addresses(id)` می‌خورند — همان‌طور که جدول بالا پیش‌بینی کرده بود.
یک تصحیح **اضافه** روی همان جدول: کلید یکتای منبعِ پزشک
`(doctor_id, address_id)` است نه `(doctor_id)`. یک `WeeklySchedule` per جفت
(پزشک، کلینیک) است ولی هر شیفتِ درونش `location_id` خودش را دارد، پس یک پزشک از قبل
در چند آدرسِ یک محیط کار می‌کند. همین برای `(staff_id, address_id)` هم صادق است.
`rooms` استثناست: اتاق ذاتاً در یک آدرس است، پس `UNIQUE(room_id)` کافی است.
تسک‌های ۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ که هنوز `branch_id` می‌گویند، همین الگو را دنبال کنند.
@@ -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)
@@ -11,90 +11,90 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `Doctor` به `Resource` تبدیل **نشد** — فقط لینک شد | | `appointments.doctor_id` سر جایش |
| ۰.۳ | `ClinicStaff` و `Room` هویت مستقل حفظ کردند | | |
| ۰.۴ | `service_item_staff` و `ServiceItem.staffMembers` دست‌نخورده | | |
| ۰.۵ | `WeeklySchedule.meta.buffer_minutes` دست‌نخورده | | `setup/cleanup` مفهوم جداست |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | ۳ تست / ۸ assertion سبز |
| ۰.۲ | `Doctor` به `Resource` تبدیل **نشد** — فقط لینک شد | | فقط پل؛ `appointments.doctor_id` دست‌نخورده |
| ۰.۳ | `ClinicStaff` و `Room` هویت مستقل حفظ کردند | | هر سه هویت مستقل ماندند |
| ۰.۴ | `service_item_staff` و `ServiceItem.staffMembers` دست‌نخورده | | صفر تغییر |
| ۰.۵ | `WeeklySchedule.meta.buffer_minutes` دست‌نخورده | | `setup/cleanup` per منبع است، آن یکی per پزشک |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `ResourceType` · `ClinicResource` · `Skill` · `ResourceSkill` | | |
| ۱.۲ | `ResourcePool` · `ResourcePoolMember` | | |
| ۱.۳ | نام کلاس `ClinicResource` (نه `Resource`) و جدول `clinic_resources` | | |
| ۱.۴ | `ResourceLinker` — تنها نقطهٔ نگاشت پزشک/پرسنل/اتاق ↔ منبع | | |
| ۱.۵ | حداکثر یکی از `doctor_id`/`staff_id`/`room_id` — قید در سازنده | | |
| ۱.۶ | `capacity>1` روی `type=doctor` → ۴۲۲ | | |
| ۱.۷ | `normalizeAttributes` — اسکالر، کلید `[a-z_]{1,40}`، سقف ۲۰ | | |
| ۱.۸ | `ResourcePoolService` — اعضا هم‌شعبه و هم‌نوع، وگرنه ۴۲۲ | | |
| ۱.۹ | `findEligible()` با `HAVING COUNT(DISTINCT skill) = n` | | همهٔ مهارت‌ها، نه یکی |
| ۱.۱۰ | `StaffService` موجود `ResourceLinker::syncActive()` صدا می‌زند | | نه lifecycle callback |
| ۱.۱۱ | چهارده endpoint | | |
| ۱.۱۲ | `app:resource:backfill` — dry-run، idempotent | | سه نوع سیستمی + پزشک/پرسنل/اتاق |
| ۱.۱۳ | `TenantOwnershipChecker` روی هر uuid از request | | |
| ۱.۱ | `ResourceType` · `ClinicResource` · `Skill` · `ResourceSkill` | | |
| ۱.۲ | `ResourcePool` · `ResourcePoolMember` | | |
| ۱.۳ | نام کلاس `ClinicResource` (نه `Resource`) و جدول `clinic_resources` | | |
| ۱.۴ | `ResourceLinker` — تنها نقطهٔ نگاشت پزشک/پرسنل/اتاق ↔ منبع | | + identity map برای نوع‌های flush-نشده |
| ۱.۵ | حداکثر یکی از `doctor_id`/`staff_id`/`room_id` — قید در سازنده | | `BackfillResourceTest::testASecondBridgeIsRefused` |
| ۱.۶ | `capacity>1` روی `type=doctor` → ۴۲۲ | | قید در entity روی هر منبعِ «شخص»، نه فقط `type=doctor` |
| ۱.۷ | `normalizeAttributes` — اسکالر، کلید `[a-z_]{1,40}`، سقف ۲۰ | | |
| ۱.۸ | `ResourcePoolService` — اعضا هم‌شعبه و هم‌نوع، وگرنه ۴۲۲ | | اعتبارسنجی پیش از حذف — اتمی |
| ۱.۹ | `findEligible()` با `HAVING COUNT(DISTINCT skill) = n` | | `ResourceEligibilityTest` هر دو حالت را می‌سنجد |
| ۱.۱۰ | `StaffService` موجود `ResourceLinker::syncActive()` صدا می‌زند | | `StaffController::toggle` صدا می‌زند؛ تست سطح endpoint دارد |
| ۱.۱۱ | چهارده endpoint | | شانزده شد نه چهارده: `GET/DELETE /resource/{uuid}` و `GET /resource-pool/{uuid}` هم لازم بودند |
| ۱.۱۲ | `app:resource:backfill` — dry-run، idempotent | | + `--pair` برای دامنه و flush per محیط |
| ۱.۱۳ | `TenantOwnershipChecker` روی هر uuid از request | | `ResourceContext` تک‌نقطهٔ ۴۰۴ |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | شش جدول ساخته شد | | |
| ۲.۲ | سه UNIQUE تهی‌پذیر روی `doctor_id`/`staff_id`/`room_id` | | |
| ۲.۳ | `idx_resource_skills_skill (skill_id, level)` | | کوئری داغ تسک ۰۶ |
| ۲.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | | |
| ۲.۵ | `resource_skills` و `resource_pool_members` در `AGGREGATE_CHILDREN` | | |
| ۲.۶ | `TenantSchemaCoverageTest` سبز | | |
| ۲.۱ | شش جدول ساخته شد | | `Version20260730132948` |
| ۲.۲ | سه UNIQUE تهی‌پذیر روی `doctor_id`/`staff_id`/`room_id` | | دو تای اول به `(doctor_id, address_id)` و `(staff_id, address_id)` تصحیح شد — پزشک در چند شعبهٔ یک محیط کار می‌کند |
| ۲.۳ | `idx_resource_skills_skill (skill_id, level)` | | کوئری داغ تسک ۰۶ |
| ۲.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | | |
| ۲.۵ | `resource_skills` و `resource_pool_members` در `AGGREGATE_CHILDREN` | | ریشه‌هاشان جفت دارند، پس ارث‌بری واقعی است |
| ۲.۶ | `TenantSchemaCoverageTest` سبز | | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `ResourcesPage` · `ResourceFormPage` · `ResourceTypesPage` · `SkillsPage` · `ResourcePoolsPage` | | |
| ۳.۲ | فیلتر شعبه/نوع/فعال در URL با `useUrlState` | | |
| ۳.۳ | شعبه و نوع با `SearchableSelect` | | |
| ۳.۴ | مهارت‌ها با چیپ چندانتخابی | | |
| ۳.۵ | `DataTable` با skeleton و empty state | | |
| ۳.۶ | `backTo`/`BackButton` روی همهٔ زیرصفحه‌ها | | |
| ۳.۷ | هیچ رنگ/شعاع hard-code | | |
| ۳.۸ | دارک‌مود و حالت فشرده | | |
| ۳.۹ | RTL و موبایل | | |
| ۳.۱۰ | `setup/cleanup` با واحد فارسی «دقیقه» | | |
| ۳.۱۱ | هشدار «این منبع N نوبت آیندهٔ فعال دارد» هنگام غیرفعال‌سازی | ⏳ | |
| ۳.۱ | `ResourcesPage` · `ResourceFormPage` · `ResourceTypesPage` · `SkillsPage` · `ResourcePoolsPage` | | `ResourceFormPage` به‌صورت مودال شد نه صفحه: فرم کوتاه است و صفحهٔ جدا یک ناوبری اضافه بدون سود می‌داد |
| ۳.۲ | فیلتر شعبه/نوع/فعال در URL با `useUrlState` | | شعبه/نوع/مهارت/وضعیت — هر چهار در URL و مستقیم به سرور |
| ۳.۳ | شعبه و نوع با `SearchableSelect` | | هیچ `<select>` بومی در کل دامنه |
| ۳.۴ | مهارت‌ها با چیپ چندانتخابی | | چیپ در جدول + مودال اختصاصی با سطح ۱..۵؛ جایگزینی کامل |
| ۳.۵ | `DataTable` با skeleton و empty state | | |
| ۳.۶ | `backTo`/`BackButton` روی همهٔ زیرصفحه‌ها | | `backTo` + breadcrumb روی هر سه زیرصفحه |
| ۳.۷ | هیچ رنگ/شعاع hard-code | | همه از `var(--…)` |
| ۳.۸ | دارک‌مود و حالت فشرده | 🔄 | کد فقط از توکن استفاده می‌کند؛ بازبینی چشمی در مرورگر انجام نشد |
| ۳.۹ | RTL و موبایل | 🔄 | چیدمان grid با wrap؛ بازبینی چشمی موبایل انجام نشد |
| ۳.۱۰ | `setup/cleanup` با واحد فارسی «دقیقه» | | برچسب «دقیقه» در فرم و ستون جدول |
| ۳.۱۱ | هشدار «این منبع N نوبت آیندهٔ فعال دارد» هنگام غیرفعال‌سازی | ⏳ | نوبت هنوز به منبع وصل نیست — شمارش «نوبت آیندهٔ این منبع» تا تسک ۰۷ ممکن نیست |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `ResourceCrudTest` — شعبهٔ محیط دیگر ۴۰۴، دو پل ۴۲۲، capacity | | |
| ۴.۲ | `SkillAssignmentTest` — جایگزینی کامل، level، حذف در استفاده | | |
| ۴.۳ | `ResourcePoolTest` — عضو شعبهٔ دیگر / نوع دیگر → ۴۲۲ | | |
| ۴.۴ | `ResourceEligibilityTest` — دو مهارت، منبع با یکی برنمی‌گردد | | |
| ۴.۵ | `BackfillResourceTest` — idempotent | | |
| ۴.۶ | `TenantLookupInventoryTest` شمارنده به‌روز شد | | |
| ۴.۱ | `ResourceCrudTest` — شعبهٔ محیط دیگر ۴۰۴، دو پل ۴۲۲، capacity | | ۱۳ تست |
| ۴.۲ | `SkillAssignmentTest` — جایگزینی کامل، level، حذف در استفاده | | ۹ تست شامل اتمی بودن |
| ۴.۳ | `ResourcePoolTest` — عضو شعبهٔ دیگر / نوع دیگر → ۴۲۲ | | ۷ تست |
| ۴.۴ | `ResourceEligibilityTest` — دو مهارت، منبع با یکی برنمی‌گردد | | ۶ تست |
| ۴.۵ | `BackfillResourceTest` — idempotent | | ۱۰ تست |
| ۴.۶ | `TenantLookupInventoryTest` شمارنده به‌روز شد | | سبز بدون نیاز به تغییر شمارنده — lookupهای تازه از `ResourceContext` رد می‌شوند |
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/resource.md` | | |
| ۵.۲ | توضیح «ظرفیت ۳ = یک ردیف، نه سه منبع» | | |
| ۵.۳ | تفاوت `setup/cleanup` منبع با `buffer_minutes` برنامه | | |
| ۵.۴ | `docs/api/staff.md` بخش «رابطه با منبع» | | |
| ۵.۵ | `docs/architecture/tenancy.md` به‌روز | | |
| ۵.۱ | `docs/api/resource.md` | | JSON واقعی از curl روی ddev + ثبت در README |
| ۵.۲ | توضیح «ظرفیت ۳ = یک ردیف، نه سه منبع» | | |
| ۵.۳ | تفاوت `setup/cleanup` منبع با `buffer_minutes` برنامه | | |
| ۵.۴ | `docs/api/staff.md` بخش «رابطه با منبع» | | بخش «رابطه با منبع» |
| ۵.۵ | `docs/architecture/tenancy.md` به‌روز | | تفاوت «ریشهٔ جفت‌دار» با پروندهٔ تسک ۰۱ |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | |
| ۶.۲ | `bin/phpunit` کامل سبز | | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `phpstan` بدون خطای جدید | | |
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۶.۶ | تست‌های tenant سبز | | |
| ۶.۷ | `docs/api/*` به‌روز | | |
| ۶.۸ | چک‌لیست UI کامل | ⏳ | |
| ۶.۹ | دو کلاینت دیگر بررسی شدند | | |
| ۶.۱۰ | commit، سپس `graphify update .` | | |
| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⚠️ | دو 🔄 بازبینی چشمی + یک ⏳ با مقصد صریح (تسک ۰۷) |
| ۶.۲ | `bin/phpunit` کامل سبز | | ۱۱۱۹ تست / ۳۱۱۳ assertion سبز |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `phpstan` بدون خطای جدید | | ۱۴ خطا قبل و بعد؛ `src/Resource` صفر خطا |
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | | `tsc` تمیز · vitest ۸۸ فایل / ۶۱۷ تست |
| ۶.۶ | تست‌های tenant سبز | | |
| ۶.۷ | `docs/api/*` به‌روز | | |
| ۶.۸ | چک‌لیست UI کامل | ⚠️ | ردیف‌های ۳.۸/۳.۹ بازبینی چشمی می‌خواهند |
| ۶.۹ | دو کلاینت دیگر بررسی شدند | | هیچ‌کدام منابع را مصرف نمی‌کنند؛ اندپوینت‌های تازه همه پنل‌محورند و قرارداد موجودی عوض نشد |
| ۶.۱۰ | commit، سپس `graphify update .` | | |
| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | هشدار نوبت آینده ← تسک ۰۷ (ردیف ۳.۱۱) |