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>
8.0 KiB
Staff API
مدیریت پرسنل مطب/کلینیک (بدون حذف — فقط toggle فعال/غیرفعال).
هر پرسنل حساب کاربری ورود دارد. هنگام ایجاد/ویرایش، یک User با نقش ROLE_STAFF ساخته
(یا کاربر موجودِ همان موبایل استفاده) و به ردیف پرسنل وصل میشود؛ بنابراین phone اجباری و
معتبر (^09\d{9}$) است و همان نامکاربری ورود است. تغییر phone یعنی تغییر نامکاربری.
چنین کاربری در /admin وارد میشود، ولی دسترسیاش به GET /api/v1/dashboard/staff و چند
مسیر حساب کاربری محدود است (بقیهٔ /api/v1/* برای او ۴۰۳ است — رجوع به
dashboard.md و auth.md). قطع دسترسی با
PATCH /api/v1/staff/{uuid}/toggle انجام میشود، نه با حذف حساب.
ردیفهای پرسنلِ ساختهشده پیش از این تغییر ممکن است
has_account: falseباشند؛ با اولین ویرایش (کهphoneمعتبر میخواهد) صاحب حساب میشوند.
GET /api/v1/staff
دریافت لیست پرسنل entity جاری (از JWT).
Permission: IS_AUTHENTICATED_FULLY (ROLE_DOCTOR یا ROLE_CLINIC)
Response 200:
{
"success": true,
"data": [
{
"uuid": "a1b2c3d4-...",
"entity_type": "clinic",
"entity_id": 5,
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"address": null,
"national_code": "0012345678",
"active": true,
"has_account": false,
"user_uuid": null,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
| فیلد | نوع | توضیح |
|---|---|---|
| has_account | bool | حساب ورود دارد یا نه — برای ردیفهای جدید همیشه true؛ false فقط در ردیفهای قدیمیِ پیش از این قابلیت |
| user_uuid | string|null | uuid کاربرِ متصل؛ null یعنی حساب ندارد |
POST /api/v1/staff
ایجاد پرسنل جدید.
Permission: IS_AUTHENTICATED_FULLY (ROLE_DOCTOR یا ROLE_CLINIC)
Request Body:
{
"full_name": "محمد رحیمی",
"phone": "09121110002",
"job_title": "پرستار",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678",
"password": "Staff@1234"
}
| فیلد | نوع | الزامی |
|---|---|---|
| full_name | string | ✅ |
| phone | string — ^09\d{9}$، ارقام فارسی به لاتین تبدیل میشوند |
✅ نامکاربری ورود |
| job_title | string | ❌ |
| address | string | ❌ |
| national_code | string(15) — ارقام فارسی به لاتین تبدیل میشوند | ❌ |
| password | string | ❌ — رمز ورود؛ خالی بگذارید تا کاربر با «فراموشی رمز» تعیینش کند. روی کاربر موجود، رمز فعلی پاک نمیشود |
اگر موبایل قبلاً User داشته باشد، کاربر جدید ساخته نمیشود؛ فقط ROLE_STAFF به نقشهایش
اضافه و به این ردیف پرسنل وصل میشود.
Response 201 (خروجی واقعی):
{
"success": true,
"data": {
"uuid": "d64826bc-5e5d-4df6-83bb-3ddc70a99636",
"entity_type": "doctor",
"entity_id": 1,
"full_name": "محمد رحیمی",
"phone": "09121110002",
"job_title": "پرستار",
"address": null,
"national_code": null,
"active": true,
"has_account": true,
"user_uuid": "4d79b9b0-f330-4dea-9f02-e38fc716b115",
"created_at": 1785393740,
"updated_at": 1785393740
}
}
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_VALIDATION_001 | 422 | full_name خالی است |
| ERR_STAFF_MOBILE_INVALID | 422 | شماره خالی/نامعتبر است یا شمارهٔ خودِ مالک محیط است (field: "phone") |
| ERR_STAFF_MOBILE_TAKEN | 409 | در همین محیط، پرسنل دیگری با این شماره ثبت شده است (field: "phone") |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
خروجی واقعی خطاها:
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_INVALID","message":"شماره موبایل پرسنل معتبر نیست","field":"phone"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_TAKEN","message":"برای این شماره قبلاً پرسنلی ثبت شده است","field":"phone"}]}
PATCH /api/v1/staff/{uuid}
ویرایش اطلاعات پرسنل.
Permission: IS_AUTHENTICATED_FULLY — فقط owner یا ROLE_ADMIN
Request Body (همه فیلدها اختیاری):
{
"full_name": "علی محمدی ویرایششده",
"phone": "09129999999",
"job_title": "منشی ارشد",
"address": null,
"national_code": null,
"password": "NewPass@123"
}
ویرایش هم حساب را میسازد/بهروز میکند: اگر phone ارسال نشود، شمارهٔ فعلی همان ردیف
استفاده میشود؛ اگر شمارهٔ جدید بیاید، نامکاربری ورود عوض میشود. password خالی رمز فعلی
را پاک نمیکند.
Response 200: همان ساختار staff object
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
| ERR_STAFF_MOBILE_INVALID | 422 | شمارهٔ نامعتبر یا شمارهٔ مالک محیط |
| ERR_STAFF_MOBILE_TAKEN | 409 | شماره در همین محیط قبلاً ثبت شده |
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
PATCH /api/v1/staff/{uuid}/toggle
تغییر وضعیت فعال/غیرفعال پرسنل (soft toggle — هیچ حذفی انجام نمیشود).
Permission: IS_AUTHENTICATED_FULLY — فقط owner یا ROLE_ADMIN
Response 200:
{
"success": true,
"data": {
"uuid": "a1b2c3d4-...",
"active": false,
...
}
}
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
رابطه با منبع
پرسنل بهتنهایی تقویم، ظرفیت و مهارت ندارد؛ آنها روی منبع مینشینند
(resource.md). هر پرسنل میتواند یک منبع از نوع staff در هر شعبه
داشته باشد و app:resource:backfill این پل را برای پرسنلِ موجود میسازد.
| نکته | چرا |
|---|---|
job_title مهارت نیست |
متن آزاد و فقط برای نمایش است؛ هیچجا برای تصمیمگیری parse نمیشود. مهارت یک موجودیت با هویت است که در انتخاب منبع و شرط قوانین استفاده میشود |
| غیرفعال کردن پرسنل | باید منبعش را هم ببندد، وگرنه در جستجوی وقت ظاهر میشود. ResourceLinker::syncActive() این کار را میکند — فراخوانی صریح، نه Doctrine lifecycle callback (که در getArrayResult() اجرا نمیشود و رفتار نامتقارن میسازد) |
| غیرفعال کردن منبع | پرسنل را غیرفعال نمیکند — پرسنل ممکن است فقط نقش اداری داشته باشد |
| پرسنلِ محیط چندشعبهای | backfill پلش را نمیسازد و گزارش میدهد: هیچ ستونی نمیگوید در کدام شعبه کار میکند و حدس زدن او را در ساختمان اشتباه مینشاند |
ظرفیت منبعِ پرسنل همیشه ۱ است — یک شخص همزمان دو بیمار ندارد.