Files
clinicpro/docs/api/resource.md
T
hamedandClaude Opus 5 0127b463a6 feat(resource): ad-hoc blocking, 409 recovery, and the rest of the flake
Ad-hoc resource blocking
- "The laser is being serviced this afternoon" is a specific range, not a change
  to the resource's working pattern. It stays separate from calendar exceptions
  and the modal says which is which — merging them means either an afternoon's
  closure lives in the calendar forever, or a change to working hours vanishes
  with one click
- Blocking a range that already holds an appointment is refused with 409 rather
  than silently taking capacity back; the appointment is still there and someone
  has to decide about it first
- Deleting an occupancy that belongs to an appointment is refused too, otherwise
  a patient's booking would quietly lose its resource with no record

409 on hold now recovers
Saying "someone just took it" is not enough — the operator would have to search
again by hand. The page drops the stale selection and refetches, so alternatives
are on screen immediately.

Flake, second half
The earlier fix only covered createUser's retry path. Any test that trips a
unique constraint closes the EntityManager, and the next test inherits the same
closed instance from the container. setUp now resets the registry when it finds
a closed manager, so a test's starting state no longer depends on how the
previous one failed.

Three consecutive full runs green: 1340 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:50:43 +03:30

18 KiB
Raw Blame History

Resource API — منابع، نوع منبع، مهارت و استخر

Base: /api/v1 · Auth: JWT روی همهٔ اندپوینت‌ها مجوز: appointment_settings (view خواندن، update نوشتن) — همان مجوز تنظیمات نوبت‌دهی؛ مجوز تازه‌ای ساخته نشده.


منبع چیست

قانون طلایی اول مستند: «تقویم مال منبع است، نه مال پزشک.» منبع هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.

هر منبع مال یک شعبه است و شعبه همان رکورد آدرس محل نوبت‌دهی است (doctor_addresses — رجوع به branch.md). پس همه‌جا address_uuid است، نه branch_id.

پل، نه ادغام

Doctor، ClinicStaff و Room هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند (appointments.doctor_id، service_item_staff، سایت عمومی). تبدیلشان به زیرکلاسِ منبع یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع حداکثر یک پل دارد:

subject_kind یعنی
doctor / staff / room منبع همان موجودیت است
null دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست

قید «حداکثر یکی» در خودِ entity اجبار می‌شود، نه با CHECK دیتابیس: MariaDB قید چندستونی را قابل اتکا اجرا نمی‌کند.

یکتایی (doctor_id, address_id) است، نه (doctor_id). یک WeeklySchedule per جفت (پزشک، کلینیک) است ولی هر شیفتِ درونش location_id خودش را دارد، پس یک پزشک از قبل در چند آدرسِ یک محیط کار می‌کند. کلید زدن فقط روی پزشک، این واقعیت را غیرقابل‌بیان می‌کرد — و تقویمِ تسک ۰۳ دقیقاً per مکان است.


سه قرارداد که باید بدانید

۱. capacity یعنی هم‌زمانی، نه تعداد ردیف. اتاق تزریق سه‌تخته یک منبع با ظرفیت ۳ است، نه سه منبع. با سه ردیف، موتور جستجو باید سه تقویم را ادغام کند و «کدام تخت» بشود تصمیمی که هیچ‌کس نمی‌خواهد بگیرد. با ظرفیت ۳، شرط اشغال یک شمارش ساده در برابر یک سقف است. منبعی که یک شخص است (پزشک/پرسنل) ظرفیت بیش از ۱ نمی‌پذیرد.

۲. setup_minutes / cleanup_minutes جزو نوبت بیمار نیستند. بیمار ساعت ۱۰:۰۰ می‌آید و ۱۰:۳۰ می‌رود؛ ولی یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست. با WeeklySchedule.meta.buffer_minutes قاطی نشود: آن فاصلهٔ سراسری بین دو نوبتِ پزشک است، این per منبع. هر دو کنار هم زندگی می‌کنند و آن یکی دست‌نخورده است.

۳. مهارت یک جدول است، نه یک قانون. «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است. با ۵۰ اپراتور و ۲۰۰ سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون. با ClinicStaff.job_title قاطی نشود: آن متن آزاد و فقط برای نمایش است و هیچ‌جا برای تصمیم‌گیری parse نمی‌شود.

⚠️ همهٔ اندپوینت‌ها برای دادهٔ محیط دیگر ۴۰۴ می‌دهند، نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمی‌رود. مالکیت صریح سنجیده می‌شود و به TenantFilter تکیه نمی‌شود، چون جداسازی سختِ فیلتر فقط روی محیطِ انتخاب‌شده اعمال می‌شود (tenancy.md).


نوع منبع

GET /api/v1/resource-types

خروجی واقعی (سه نوع سیستمی را app:resource:backfill ساخته):

{
  "success": true,
  "data": [
    { "uuid": "c39053f3-a051-4f03-942f-08a2274bf658", "code": "room",   "name": "اتاق",   "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
    { "uuid": "c0447da1-30af-430b-8cbd-b464ccd81624", "code": "staff",  "name": "پرسنل",  "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
    { "uuid": "dcabd1c3-d554-4613-a33d-7c69b7ae7efe", "code": "doctor", "name": "پزشک",   "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 1 }
  ]
}

resources_count با یک کوئری گروهی پر می‌شود، نه یکی per نوع.

POST /api/v1/resource-types

فیلد نوع الزامی قاعده
code string [a-z0-9_]{1,40} · یکتا per محیط (همان کد در محیط دیگر مجاز است)
name string نام نمایشی فارسی

۲۰۱ (خروجی واقعی):

{
  "success": true,
  "data": {
    "uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
    "code": "laser",
    "name": "دستگاه لیزر",
    "is_system": false,
    "active": true,
    "created_at": 1785420676,
    "updated_at": 1785420676,
    "resources_count": 0
  }
}

۴۲۲: کد تکراری در همان محیط (field code) · کد نامعتبر (field code) · نام خالی.

PATCH /api/v1/resource-type/{uuid}

فقط name و active. code تغییر نمی‌کند حتی روی نوع غیرسیستمی: منابع موجود و پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. فرستادنش خطا نمی‌دهد، نادیده گرفته می‌شود.

DELETE /api/v1/resource-type/{uuid}

۴۲۲ وقتی is_system است («نوع منبع سیستمی حذف نمی‌شود») یا منبعی از آن نوع وجود دارد («این نوع روی N منبع استفاده شده است»).


مهارت

GET /api/v1/skills · POST /api/v1/skills

بدنهٔ ساخت فقط name. ۲۰۱ (خروجی واقعی):

{
  "success": true,
  "data": {
    "uuid": "48e60355-6788-4202-a775-a07986658662",
    "name": "لیزر آلکساندرایت",
    "active": true,
    "created_at": 1785420692,
    "updated_at": 1785420692,
    "resources_count": 0
  }
}

PATCH /api/v1/skill/{uuid}name و active

DELETE /api/v1/skill/{uuid}

مهارتی که روی منبعی نشسته حذف نمی‌شود؛ وگرنه ON DELETE RESTRICT خطای خام دیتابیس می‌داد. ۴۲۲ (خروجی واقعی):

{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"این مهارت به 1 منبع داده شده است؛ اول از آن‌ها برداشته شود"}]}

رقم لاتین در پیام عمدی است — قرارداد پیام‌های درون‌ریزیِ بک‌اند همین است و قالب‌بندی فارسی کارِ نمایش در کلاینت است.


منابع

GET /api/v1/resources

Query توضیح
address_uuid فقط منابع این شعبه
type_uuid فقط این نوع
skill_uuid فقط منابعی که این مهارت را دارند
active 1 / 0
clinic_uuid انتخاب صریح محیط

skill_uuid متعلق به محیط دیگر ۴۰۴ می‌دهد، نه «هیچ نتیجه» — سکوت اینجا یعنی دیباگِ کور.

POST /api/v1/resource

فیلد نوع الزامی قاعده
address_uuid string شعبه؛ جفت محیطِ منبع از همین مشتق می‌شود، نه از بدنه
type_uuid string
name string حداکثر ۱۵۰ نویسه
capacity int پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱
setup_minutes int پیش‌فرض ۰، بازهٔ ۰..۴۸۰
cleanup_minutes int پیش‌فرض ۰، بازهٔ ۰..۴۸۰
attributes object حداکثر ۲۰ کلید · کلید [a-z_]{1,40} · مقدار فقط اسکالر
active bool پیش‌فرض true

attributes عمداً آزاد است — کلید ناشناخته پذیرفته می‌شود — ولی مقدارش باید ساده باشد. دلیل: تسک ۰۵ قید same_gender و تسک ۰۹ شرط‌های منبع را با مقایسهٔ ساده روی همین مقادیر می‌سنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند ممنوع کرده. کلیدهای قراردادی: gender, device_model, floor, brand.

۲۰۱ (خروجی واقعی):

{
  "success": true,
  "data": {
    "uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
    "name": "لیزر آلکساندرایت ۱",
    "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
    "address_name": "درمانگاه شبانه روزی صدرا ",
    "type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
    "type_code": "laser",
    "type_name": "دستگاه لیزر",
    "capacity": 1,
    "setup_minutes": 5,
    "cleanup_minutes": 10,
    "attributes": { "device_model": "Candela GentleLase", "floor": "2" },
    "subject_kind": null,
    "subject_uuid": null,
    "skills": [],
    "active": true,
    "created_at": 1785420692,
    "updated_at": 1785420692
  }
}

۴۲۲ — ظرفیت روی منبعِ شخص (خروجی واقعی):

{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منبعی که یک شخص است نمی‌تواند ظرفیت بیش از ۱ داشته باشد","field":"capacity"}]}

۴۲۲ — ویژگی غیر اسکالر (خروجی واقعی):

{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"مقدار ویژگی «nested» باید یک مقدار ساده باشد","field":"attributes"}]}

سایر ۴۲۲ها: capacity < 1 · نام خالی · کلید ویژگی غیر snake_case · setup/cleanup بیرون از ۰..۴۸۰.

GET/PATCH/DELETE /api/v1/resource/{uuid}

PATCH همان فیلدهاست؛ address_uuid پذیرفته نمی‌شود (جفت محیط از آدرس مشتق شده و write-once است) و type_uuid قابل تغییر است.

PUT /api/v1/resource/{uuid}/skills

جایگزینی کامل: مهارتی که در بدنه نیست، برداشته می‌شود. {"skills":[]} همه را پاک می‌کند.

{ "skills": [ { "skill_uuid": "48e60355-…", "level": 4 } ] }

level بین ۱ و ۵ (پیش‌فرض ۱). از روز اول هست چون استراتژی «حفظ متخصص‌ها» در تسک ۰۶ رویش ساخته می‌شود و افزودنش بعداً یعنی backfill با حدس.

پاسخ ۲۰۰ کلِ منبع است؛ بخش skills آن (خروجی واقعی):

"skills": [
  { "skill_uuid": "48e60355-6788-4202-a775-a07986658662", "skill_name": "لیزر آلکساندرایت", "level": 4 }
]

۴۲۲: level بیرون از ۱..۵ (field level) · مهارت تکراری در یک بدنه · skill_uuid غایب. ۴۰۴: مهارت محیط دیگر.

اتمی است. اعتبارسنجی کل فهرست پیش از هر حذفی انجام می‌شود، پس یک ردیف نامعتبر در انتهای فهرست، مهارت‌های درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند.


استخر منابع

گروهی از منابع که جایگزین کامل یکدیگرند: «لیزرهای آلکساندرایت»، «اتاق‌های معاینه».

GET/POST /api/v1/resource-pools

بدنهٔ ساخت: address_uuid · type_uuid · name.

GET/PATCH/DELETE /api/v1/resource-pool/{uuid}

PATCH فقط name و active. DELETE اعضا را با CASCADE می‌برد ولی خودِ منابع دست‌نخورده می‌مانند.

PUT /api/v1/resource-pool/{uuid}/members

{ "members": [ { "resource_uuid": "38bdd1e9-…", "priority": 0 } ] }

هر عضو باید همان شعبه و همان نوعِ استخر را داشته باشد. تسک ۰۶ فرض می‌کند هر عضو جایگزین کامل دیگری است: عضوی از شعبهٔ دیگر یعنی بیمار در ساختمان اشتباه می‌ایستد، و عضوی از نوع دیگر یعنی صندلی به‌جای دستگاه لیزر پیشنهاد می‌شود.

priority ترتیب ترجیح در استراتژی انتخاب است؛ کوچک‌تر زودتر.

۲۰۰ (خروجی واقعی):

{
  "success": true,
  "data": {
    "uuid": "9d50cf4a-bef6-423c-85e9-530bf3609964",
    "name": "لیزرهای آلکساندرایت",
    "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
    "address_name": "درمانگاه شبانه روزی صدرا ",
    "type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
    "type_code": "laser",
    "type_name": "دستگاه لیزر",
    "members": [
      { "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", "resource_name": "لیزر آلکساندرایت ۱", "priority": 0, "active": true }
    ],
    "active": true,
    "created_at": 1785420708,
    "updated_at": 1785420708
  }
}

۴۲۲: «همهٔ اعضای استخر باید در یک شعبه باشند» · «… از یک نوع منبع باشند» · عضو تکراری. مثل مهارت‌ها، اعتبارسنجی پیش از حذف است.

استخر بدون عضو معتبر است (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» می‌بیند.


app:resource:backfill

هر موجودیت قابل‌اشغالِ موجود را به یک منبع پل می‌زند.

ddev exec php bin/console app:resource:backfill                    # dry-run
ddev exec php bin/console app:resource:backfill --force
ddev exec php bin/console app:resource:backfill --force --pair=clinic:12
مورد رفتار
اتاق آدرسش را خودش دارد → منبع با همان capacity و همان active
پزشک یک منبع per آدرسی که در برنامهٔ هفتگی شیفت فعال دارد (location_id)
پرسنل فقط اگر محیط دقیقاً یک شعبه دارد؛ وگرنه رد و گزارش می‌شود

پرسنل تنها موردی است که قابل استنتاج نیست: هیچ ستونی نمی‌گوید در کدام شعبه کار می‌کند. حدس زدنِ «اولین شعبه» او را در ساختمان اشتباه می‌نشاند، پس گزارش می‌شود تا کاربر خودش تعیین کند.

--pair هم برای عملیات است (اجرای دوباره برای یک کلینیک) و هم دامنه را محدود می‌کند. دستور per محیط flush می‌کند، پس یک ردیف خراب کل اجرای چندهزارمحیطی را با EntityManagerClosed از پا نمی‌اندازد.

idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ متناظر است، نه یک پرچم جداگانه.


طبقه‌بندی محیط

جدول وضعیت
resource_types · clinic_resources · skills · resource_pools جفت (entity_type, entity_id)
resource_skills · resource_pool_members AGGREGATE_CHILDREN — ریشه‌هاشان خودشان جفت دارند

تست‌ها

ddev exec php bin/phpunit tests/Resource     # ۵۲ تست / ۱۲۹ assertion
ddev exec php vendor/bin/phpstan analyse src/Resource
npx vitest run assets/admin/pages/ResourcesPage.test.tsx

مسدودسازی موردی

«این بعدازظهر دستگاه سرویس دارد» — یک بازهٔ مشخص که منبع در دسترس نیست.

مسدودسازی موردی استثنای تقویم
چیست یک بازهٔ مشخص تغییر الگوی تکرارشوندهٔ کاری
کجا resource_occupancy resource_exceptions
چقدر می‌ماند تا وقتی حذفش کنی بخشی از تعریف تقویم

ادغامشان یعنی یا تعطیلی یک بعدازظهر برای همیشه در تقویم بماند، یا تغییر ساعت کاری با یک کلیک ناپدید شود.

GET /api/v1/resource/{uuid}/blocks

Query Type Description
from / to int پیش‌فرض: از حالا تا ۳۰ روز بعد

فقط مسدودسازی‌های دستی برمی‌گردند؛ اشغالِ نوبت‌ها اینجا نمی‌آید.

POST /api/v1/resource/{uuid}/blocks

{ "starts_at": 1785600000, "ends_at": 1785614400, "reason": "سرویس دوره‌ای دستگاه" }
Code HTTP Description
ERR_VALIDATION_002 422 بازه غایب
ERR_VALIDATION_001 422 پایان قبل از شروع
ERR_SLOT_TAKEN 409 در این بازه نوبت یا رزرو موقت هست

مسدودسازی روی بازه‌ای که نوبت دارد ظرفیت را پس نمی‌گیرد: نوبت سرجایش می‌ماند و کاربر باید اول تکلیفش را روشن کند.

DELETE /api/v1/resource-block/{uuid}

اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف نمی‌شود (422) — وگرنه نوبت بیمار بی‌صدا منبعش را از دست می‌داد.