Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:
- AppointmentCompleted fires from both status-change routes, after the row is
saved. A rejected transition or a version conflict leaves no event; otherwise
the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
confirm plus a cancel, and a consumer that only hears the cancel messages a
patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
as audible as capacity going away, or the resource reads as permanently taken.
Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.
Panel work that these paths made reachable:
- Cancelling from the appointment page now goes through the policy-aware
endpoint and shows the penalty preview before the confirm, so the operator
does not discover the patient's penalty after the fact. The cancellation
service writes the timeline entry itself and accepts a reason, which that
path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
hold steps are identical and only the final step differs. The doctor picker
is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
list is not an error, it means the appointment is slot-based, and that is
exactly what gates the resource-mode reschedule button.
AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
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) — وگرنه
نوبت بیمار بیصدا منبعش را از دست میداد.
هر دو عمل رویداد دامنه ثبت میکنند: ResourceBlocked و ResourceReleased. ظرفیتی که
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
بشنود، منبع را برای همیشه اشغال میبیند.