- Implemented PublicResourceBookingController to handle public resource booking requests. - Added methods for retrieving bookable resources, available slots, and month availability. - Created PublicResourceBookingService to manage public resource offerings and service visibility. - Developed tests for public resource booking to ensure correct functionality and error handling.
49 KiB
Resource API — منابع، نوع منبع، مهارت و استخر
Base:
/api/v1· Auth: JWT روی همهٔ اندپوینتها مجوز:appointment_settings(viewخواندن،updateنوشتن) — همان مجوز تنظیمات نوبتدهی؛ مجوز تازهای ساخته نشده.استثنای خواندن برای نوبتدهی (2026-08): چهار اندپوینتِ خواندنی که ورودیِ ثبت نوبتاند —
GET /resources،GET /resource/{uuid}/services،GET /resource/{uuid}/day-slots،GET /resource/{uuid}/service-slots— باappointments.viewهم باز میشوند. منشی یا پزشکِ عضوی که اجازهٔ ثبت نوبت دارد ولی تنظیمات نوبتدهی برایش بسته است، وگرنه نمیتوانست همان نوبتی را که مجاز است ثبت کند. نوشتن همچنان فقطappointment_settings.update.
منبع چیست
قانون طلایی اول مستند: «تقویم مال منبع است، نه مال پزشک.» منبع هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
هر منبع مال یک محل نوبتدهی است — همان رکورد doctor_addresses. پس همهجا
address_uuid است، نه branch_id. مفهوم «شعبه» از محصول حذف شد و خودِ آدرس فقط لنگرِ
نامرئیِ محیط ماند؛ فهرستش در doctor.md.
پل، نه ادغام
Doctor و ClinicStaff هرکدام هویت مستقل و مصرفکنندهٔ زنده دارند
(appointments.doctor_id، service_item_staff، سایت عمومی). تبدیلشان به زیرکلاسِ منبع
یعنی مهاجرت همزمان همهٔ آن مسیرها. بهجایش هر منبع حداکثر یک پل دارد:
subject_kind |
یعنی |
|---|---|
doctor / staff |
منبع همان موجودیت است |
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 | ✅ | نام نمایشی فارسی |
field_schema |
array|null | ❌ | فیلدهای فرم ثبت درمان — پایینتر |
۲۰۱ (خروجی واقعی):
{
"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 و field_schema. code تغییر نمیکند حتی روی نوع غیرسیستمی:
منابع موجود و پل خودکار با همان کد پیدا میشوند و عوض کردنش نگاشت را بیصدا میشکند.
فرستادنش خطا نمیدهد، نادیده گرفته میشود.
نبودنِ کلید field_schema یعنی «دست نزن»؛ null یا آرایهٔ خالی یعنی «این نوع منبع فرمی
ندارد» و هر دو به null ذخیره میشوند.
فرم ثبت درمان
هر نوع منبع میگوید اپراتور بعد از درمانِ هر ناحیه با آن چه چیزی ثبت کند. تعریف اینجاست نه روی سرویس، چون خودِ دستگاه تعیین میکند چه چیزی خواندنی است: لیزر انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. افزودن دستگاه تازه تنظیمات است، نه migration.
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
key |
string | ✅ | ^[a-z][a-z0-9_]{0,39}$ · یکتا در همان schema |
label |
string | ✅ | برچسب فارسی که به اپراتور نشان داده میشود |
type |
string | ✅ | select یا number یا text — همین سه |
options |
array | فقط برای select |
مقادیر ساده؛ فهرست خالی رد میشود |
required |
bool | ❌ | پیشفرض false |
sort_order |
int | ❌ | پیشفرض ترتیب آرایه؛ خروجی بر همین اساس مرتب میشود |
حداکثر ۲۰ فیلد. مقدار text حداکثر ۵۰۰ نویسه.
خروجی واقعی PATCH روی یک نوعِ لیزر:
{
"key": "energy",
"label": "انرژی",
"type": "select",
"required": true,
"sort_order": 0,
"options": [7, 8, 9, 10, 12, 14, 16, 18]
}
۴۲۲ های واقعی:
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"نوع فیلد «x» باید یکی از select، number، text باشد","field":"type"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"فیلد انتخابی «energy» باید گزینه داشته باشد","field":"options"}]}
مقادیر هم با همین تعریف سنجیده میشوند، وقتی اپراتور ناحیهای را تمام میکند:
- کلیدی که در schema نیست رد میشود، نه اینکه بیصدا ذخیره شود — وگرنه اپراتور فکر میکند چیزی ثبت کرده که هیچوقت دیده نمیشود.
- مقدار خارج از
optionsرد میشود؛ مقایسه رشتهای است تا"18"و18یک گزینه باشند. - فیلد
requiredکه نیامده باشد ۴۲۲ میگیرد؛ فیلد اختیاری از خروجی حذف میشود. - برای نوع منبعی که
field_schemaندارد، فرستادن هر مقداری ۴۲۲ است.
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 | ✅ | |
supervisor_doctor_uuid |
string | ✅ | پزشکِ ناظرِ منبع. باید پزشکِ همین محیط باشد وگرنه ۴۰۴ |
name |
string | ✅ | حداکثر ۱۵۰ نویسه |
capacity |
int | — | پیشفرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ |
setup_minutes |
int | — | پیشفرض ۰، بازهٔ ۰..۴۸۰ |
cleanup_minutes |
int | — | پیشفرض ۰، بازهٔ ۰..۴۸۰ |
attributes |
object | — | حداکثر ۲۰ کلید · کلید [a-z_]{1,40} · مقدار فقط اسکالر |
active |
bool | — | پیشفرض true |
سقف منابع بر اساس پلن اشتراک (2026-08)
ساخت منبع به سقفِ پلنِ مؤثرِ محیط محدود است — subscription_plans.max_resources:
| پلن | سقف |
|---|---|
بدون اشتراک فعال (free) |
۱ منبع |
basic — شامل دورهٔ آزمایشی |
۳ منبع |
professional |
نامحدود (-1) |
- شمارش روی همهٔ منابع همان جفتِ محیط است: فعال و غیرفعال، و منابعِ پلِ پزشک/پرسنل هم شمرده میشوند. غیرفعالکردن جای خالی نمیسازد؛ فقط حذف میسازد.
- سقف پیش از هر اعتبارسنجی دیگری سنجیده میشود، پس بدنهٔ ناقص هم همین خطا را میگیرد.
- سقف فقط روی همین اندپوینت است. پلِ خودکارِ منبع برای پزشک/پرسنل مسدود نمیشود، ولی در شمارش میآید.
- ۴۲۲
ERR_RESOURCE_LIMIT_001: «پلن فعلی حداکثر N منبع را پشتیبانی میکند؛ برای افزودن، پنل را ارتقا دهید».
سقف در GET /api/v1/subscription/my زیر effective_plan.max_resources میآید؛ پنل با
همان و شمارشِ GET /api/v1/resources دکمهٔ افزودن را میبندد. تست:
tests/Resource/ResourceQuotaTest.php.
شعبه از منابع حذف شد (2026-08)
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیکاند، و آن انتخابگر همیشه یک گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
| قبل | حالا |
|---|---|
address_uuid در ساخت منبع و استخر الزامی |
اختیاری؛ نیامدنش = آدرسِ خودِ محیط (اولین آدرس) |
| پنل «شعبه» میپرسید و ستون/فیلترش را داشت | هیچجای پنل شعبه پرسیده یا نشان داده نمیشود |
doctor_addresses.active = 0 روزِ منبع را خالی میکرد (address_inactive) |
فعالبودنِ آدرس دیگر گیت نیست |
ستون address_id سرِ جایش میماند: منطقهٔ زمانی و جفتِ محیطِ منبع از آن میآیند. فقط
دیگر تصمیمِ کاربر نیست.
حذف گیتِ address_inactive یک باگ واقعی را میبندد: یک ردیف آدرسِ قدیمی با active = 0
همهٔ دستگاههای آن کلینیک را با پیامی خاموش میکرد که هیچ صفحهای در پنل راهی برای
روشنکردنش نداشت — هیچ اندپوینتی هم active آدرس را نمینویسد.
محیطی که هیچ آدرسی ندارد، 422 میگیرد با پیام «برای این محیط آدرسی ثبت نشده است —
ابتدا آدرس کلینیک را کامل کنید»، نه یک خطای مبهم.
تست: tests/Resource/ResourceWithoutBranchTest.php.
service_section روی فهرست سرویسهای منبع (2026-08)
GET /api/v1/resource/{uuid}/services برای هر سرویس service_section را هم میدهد
({uuid, name}). مودالِ رزرو منبع سرویسها را زیر بخششان گروه میکند — مثل نوبتدهی
سرویسیِ پزشک — و بدون این فیلد فهرست یک لیستِ تختِ بیدسته میشد.
خروجی واقعی:
{
"service_name": "لیزر زیربغل",
"service_section": { "uuid": "9eac29e4-…", "name": "خدمات لیزر و زیبایی" },
"effective_duration_minutes": 20,
"active": true
}
پزشک ناظر (2026-08)
هر منبع الزاماً زیر نظر یک پزشک است؛ صفحهٔ نوبتها تب منابع را زیر همان پزشک میچیند، پس منبعِ بیناظر جایی برای دیدهشدن ندارد.
supervisor_doctor_uuidدر ساخت الزامی است → نبودش422باfield: supervisor_doctor_uuidو پیام «انتخاب پزشک ناظر الزامی است».- در
PATCHاختیاری است، ولی اگر بیاید نمیتواند خالی باشد (برداشتن ناظر ممنوع). - پزشکِ خارج از محیط →
404(«پزشک ناظر یافت نشد») — وجود دادهٔ محیط بیگانه لو نمیرود. - پاسخها فیلد
supervisorرا به شکل{uuid, name}برمیگردانند (یاnullبرای ردیفهایی که پزشکشان حذف شده — کلید خارجیSET NULLاست).
ناظر با پلِ منبع فرق دارد. subject_kind/doctor_id یعنی «این منبع خودِ همان
پزشک است» و isPerson() بر پایهاش ظرفیت را به ۱ قفل میکند. ناظرِ یک دستگاهِ
سهظرفیتی نباید آن را به منبعِ انسانی تبدیل کند، پس ستون جداست.
خروجی واقعی GET /api/v1/resources?active=1:
{
"name": "اتاق ۱",
"supervisor": { "uuid": "631e81d8-0009-4e01-a40f-3029905a3f27", "name": "امیر کاظمی" },
"subject_kind": null,
"capacity": 1
}
backfill: ۱۳ منبعِ موجود در migration ناظر گرفتند — منبعِ مطب → همان پزشک، منبعِ کلینیک → پزشکِ اولِ همان کلینیک. قابل تغییر از فرم ویرایش منبع.
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 قابل تغییر است.
GET /api/v1/resource/{uuid}/services
سرویسهایی که این منبع ارائه میدهد، با مقدار مؤثر و اینکه هر عدد از کدام سطح آمده.
duration_minutes/price_rials مقدارِ ثبتشده روی همین رابطهاند و null یعنی «ارث از
سطح بالاتر»، نه صفر. effective_* نتیجهٔ زنجیرهٔ حل است و *_source میگوید کدام سطح
برنده شده — بدون آن، پنل نمیتواند کنار خانهٔ خالی بنویسد عدد از کجا میآید.
زنجیره از خاص به عام: resource_option → resource_service → branch → service_default.
خروجی واقعی (۲۰۰):
[
{
"service_uuid": "f49baba9-68e9-4d61-aa90-fc8c784607e0",
"service_name": "لیزر CO2",
"duration_minutes": null,
"price_rials": null,
"active": true,
"effective_duration_minutes": 40,
"effective_price_rials": 18000000,
"duration_source": "service_default",
"price_source": "branch"
},
{
"service_uuid": "7f13ab0d-2f64-4e8c-8e12-154172b6620a",
"service_name": "ویزیت عمومی",
"duration_minutes": null,
"price_rials": 1200000,
"active": true,
"effective_duration_minutes": 15,
"effective_price_rials": 1200000,
"duration_source": "service_default",
"price_source": "resource_option"
}
]
دسترسی: appointment_settings.view یا appointments.view. ۴۰۴: منبع محیط دیگر.
PUT /api/v1/resource/{uuid}/services
جایگزینی کامل، مثل مهارتها: سرویسی که در بدنه نیست از این منبع برداشته میشود و
{"services":[]} همه را پاک میکند.
{
"services": [
{ "service_uuid": "f49baba9-…", "duration_minutes": 15, "price_rials": 9500000, "active": true },
{ "service_uuid": "7f13ab0d-…", "duration_minutes": "", "price_rials": null }
]
}
| فیلد | نوع | الزامی | توضیح |
|---|---|---|---|
service_uuid |
string (UUID) | ✅ | سرویس یا گزینهٔ سرویس؛ هر دو ServiceItem اند |
duration_minutes |
int | null | "" |
❌ | مدت اختصاصی این منبع. null و رشتهٔ خالی یعنی ارث، نه صفر. مقدار ≤ ۰ ⇒ 422 |
price_rials |
int | null | "" |
❌ | همان قاعده؛ منفی ⇒ 422. صفرِ صریح یعنی رایگان و ارث نمیگیرد |
active |
boolean | ❌ | پیشفرض true. غیرفعال یعنی «فعلاً این را نمیدهد» ولی اعداد ذخیرهشده میمانند |
پاسخ ۲۰۰ همان فهرست GET است (با مقادیر تازه حلشده).
۴۲۲: services غایب یا غیرآرایه · service_uuid غایب یا ناموجود · سرویسِ محیط دیگر
(«سرویس انتخابشده به این محل نوبتدهی تعلق ندارد») · مدت یا قیمت نامعتبر.
این رابطه روی انتخاب منبع اثر میگذارد. وقتی برای یک سرویس دستکم یک ردیف ثبت شده باشد،
appointment-availabilityفقط منابعی از همان نوع را کاندید میکند که ردیف فعال دارند. تا وقتی هیچ ردیفی نیست، هیچ فیلتری اعمال نمیشود — محیطی که هنوز رابطهها را پر نکرده نباید یکشبه بیوقت شود.
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 /api/v1/resources/timeline
مجوز: appointment_settings.view. پشتِ نمای منابع در صفحهٔ نوبتها
(/admin/appointments) است.
| پارامتر | پیشفرض | توضیح |
|---|---|---|
date |
امروز | YYYY-MM-DD — هر قالب دیگری ۴۲۲ |
address_uuid |
همهٔ محلها | فقط منابع همان محل نوبتدهی |
only_bookable |
0 |
فقط منابعی که دستکم یک سرویسِ فعال ارائه میدهند |
فقط منابع فعال برمیگردند و منبعِ بیشیفت هم در فهرست میماند تا ردیفش در تایملاین دیده شود.
بازهها از resource_occupancy میآیند نه از خودِ نوبت: بازهٔ اشغال، آمادهسازی و
تمیزکاری منبع را هم در بر دارد و همان بازهای است که موتور جستجو اشغال میبیند. ردیف
released نمیآید؛ آن تاریخچه است. یک نوبتِ چندبخشی روی چند منبع، چند ردیف دارد —
همان چیزی که نمای پزشکمحور نشان نمیدهد.
خروجی واقعی (سناریوی ۲، ۲۰۲۶-۰۸-۰۵):
{
"success": true,
"data": {
"date": 1785875400, // نیمهشب همان روز
"day_of_week": 4, // ۰ = شنبه
"resources": [
{
"uuid": "…", "name": "اتاق لیزر ۱", "type_name": "اتاق درمان",
"address_name": "درمانگاه سلامت", "capacity": 1,
"shifts": [{ "start_minute": 480, "end_minute": 1260 }],
"shift_minutes": 780, "busy_minutes": 55, "free_minutes": 725,
"items": [
{
"uuid": "6176e73b-df27-4cdf-815c-36f9bfbd68ca",
"starts_at": 1785931200, "ends_at": 1785931500,
"status": "booked", "segment_name": "بیحسی موضعی",
"appointment_id": 47, "patient_name": "زهرا احمدی",
"appointment_uuid": "17086c41-6cc4-4539-bb5b-4c94e8f373c6",
"appointment_status": "pending"
}
]
}
]
}
}
shifts فقط شیفتهای همان روزِ هفته است. ۴۲۲: قالب date غلط
({"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}).
وقت آزاد با ظرفیت حساب میشود، نه پر/خالی. دقیقهای «پر» است که تعداد بازههای
همپوشانش به capacity رسیده باشد؛ اتاق سهتخته با دو نوبت همزمان هنوز آزاد است. اگر
جز این بود، ظرفیت عملاً یک میشد. محاسبه در ResourceFreeTimeCalculator است و
tests/Resource/ResourceFreeTimeCalculatorTest.php نُه حالتش را قفل میکند.
خروجی واقعی only_bookable=1 روی سناریوی ۲: از پنج منبع، فقط «لیزر الکساندرایت ۱» و
«لیزر دایود ۲» برمیگردند — سهتای دیگر هنوز به هیچ سرویسی وصل نشدهاند و ردیفِ
همیشهخالی میساختند.
پنل: ردیفهای این اندپوینت زیر نمای «زمانبندی» صفحهٔ نوبتها میآیند، نه در یک نمای سوم — پرشدن یک ساعت را دستگاه و اتاق تعیین میکنند نه فقط برنامهٔ پزشک، و دو نمای جدا یعنی کاربر باید آنها را با چشم تطبیق دهد.
تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی بهازای هر منبع.
GET /api/v1/resource/{uuid}/day-slots (2026-08)
مجوز: appointment_settings.view یا appointments.view. پارامتر: date=Y-m-d (الزامی).
بازههای کاری منبع در یک روز — ورودیِ تایملاینِ سرویسیِ صفحهٔ نوبتها. معادلِ
appointment-slots پزشک، ولی از تقویم خودِ منبع: ساعت شعبه ∩ شیفت منبع − تعطیلات −
استثناها.
نوبتها اینجا کسر نمیشوند: تایملاین نوبتهای همان روز را جدا دارد و کارتها را داخل همین بازهها میچیند؛ کسرشان یعنی نوبتِ ثبتشده جایی برای نشستن ندارد.
{ "success": true, "data": {
"resource_uuid": "ce07…", "date": "2026-08-04", "timezone": "Asia/Tehran",
"windows": [{ "start": 1785220200, "end": 1785249000, "start_time": "09:00", "end_time": "17:00" }],
"empty_reason": null
} }
empty_reason وقتی windows خالی است میگوید چرا: no_shift، national_holiday،
tenant_holiday، exception، resource_inactive. خالیبودن خطا
نیست و کلاینت نباید همه را «تعطیل» بنامد.
GET /api/v1/resource/{uuid}/service-slots (2026-08)
مجوز: appointment_settings.view یا appointments.view.
| پارامتر | توضیح |
|---|---|
date |
Y-m-d، الزامی |
service_item_uuids[] |
یک یا چند سرویس؛ خالی ⇒ 422 |
durations[<uuid>] |
override مدت، فقط برای همین محاسبه |
زمانهای خالیِ کافی برای مجموعِ مدتِ سرویسهای انتخابشده — معادلِ
appointment-service-slots پزشک. مدت هر سرویس از زنجیرهٔ حلِ همان منبع میآید
(ResourceServiceResolver)، اشغال از نوبتهای resource_id و ردیفهای
resource_occupancy خوانده میشود، و ظرفیت منبع رعایت میشود. زمانها پشتسرهم
چیده میشوند (بدون بافر) و زمانِ گذشته پیشنهاد نمیشود.
{ "success": true, "data": {
"resource_uuid": "ce07…", "date": "2026-08-04", "total_duration_minutes": 60,
"start_times": [{ "start": 1785220200, "end": 1785223800, "start_time": "09:00", "end_time": "10:00" }]
} }
۴۲۲: سرویسِ ناموجود · سرویسی که این منبع ارائه نمیدهد (یا offeringش غیرفعال است) · سرویسِ بیمدت · تاریخ بدفرم. ۴۰۴: منبعِ محیط دیگر.
ثبتِ خودِ نوبت با همین زمانها از POST /api/v1/my/appointment با resource_uuid
انجام میشود (appointment.md).
نوبتدهی عمومیِ منبعمحور (سایت) (2026-08)
سه اندپوینتِ بدون احراز هویت که سایت عمومی (nobat724_front) با آنها منبع را کشف
میکند و روی تقویم خودِ منبع نوبت میگیرد. کنترلرشان
src/Resource/Controller/PublicResourceBookingController.php است — عمداً جدا از
ResourceBookingSlotController که منبع را از محیطِ کاربرِ احرازشده حل میکند.
گیتِ عمومیشدن یک قاعده است و در PublicResourceBookingService یکجا تعریف شده:
منبع فعال باشد، ردیفِ ارائه (resource_service_offerings) فعال باشد، و سرویسِ آن ردیف
هم bookable (توگل «نمایش در نوبتدهی آنلاین») و هم active باشد. منبعی که هیچ سرویسِ
روشنی ندارد اصلاً در پاسخ نمیآید.
GET /api/v1/appointment-booking-resources/{doctorUuid} (2026-08)
عمومی — بدون توکن.
| پارامتر | توضیح |
|---|---|
clinic_uuid |
اختیاری. نبودش یعنی همهٔ محیطهای این پزشک — مطب شخصی بهعلاوهٔ هر کلینیکی که عضوش است |
فقط منابعی برمیگردند که پزشکِ همین صفحه یا ناظرشان است (supervisor_id) یا خودشان
پلِ همان پزشکاند (doctor_id). duration_minutes و price_rials هر سرویس از زنجیرهٔ
حلِ همان منبع میآیند (ResourceServiceResolver)، نه از پیشفرضِ خامِ سرویس.
هر منبع clinic_uuidِ محیطِ خودش را همراه دارد (تهی = مطب شخصی) و سایت نوبت را با همان
ثبت میکند. این عمدی است: منبع تقویم و شعبهٔ خودش را دارد و به برنامهٔ هفتگیِ پزشک وابسته
نیست، پس پزشکی که خودش نوبت آنلاین نمیدهد هیچ «محل نوبتدهی»ای ندارد که سایت
clinic_uuid را از آن بردارد. اگر پاسخِ بدون پارامتر فقط مطب شخصی را میداد، دستگاهِ
قابلِ رزروِ چنین پزشکی هرگز در سایت پیدا نمیشد.
خروجی واقعی (اجرای محلی، بدون هدر Authorization):
{
"success": true,
"data": {
"doctor_uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
"resources": [
{
"uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"name": "کندلا2021",
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
"type": { "code": "laser_device", "name": "دستگاه لیزر" },
"location": {
"uuid": "6cafca59-8261-47f6-93d2-2d6e16f6aeb3",
"title": "کلنیک مدیسا",
"address": ""
},
"supervisor": {
"uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
"full_name": "پزشک دعوتشده"
},
"services": [
{
"uuid": "f3e8f166-8ec0-479b-a82a-b5133bb06698",
"name": "لیزیر دست",
"duration_minutes": 20,
"price_rials": 2000000,
"service_section": {
"uuid": "fff74b7f-da59-4928-a549-e3ba96b43119",
"name": "لیزیر"
}
}
]
}
]
}
}
۲۰۰ با resources: [] — پزشکِ بدون منبع، منبعِ غیرفعال، سرویسِ خاموش، یا ردیفِ ارائهٔ
غیرفعال. هیچکدام خطا نیستند.
۴۰۴: پزشک یافت نشد (ERR_VALIDATION_002) · clinic_uuidی که پزشک عضوش نیست
(«محل نوبتدهی یافت نشد»).
capacity عمداً در پاسخ نیست: عددِ عملیاتیِ داخلِ کلینیک است و سایت مصرفی برایش ندارد.
GET /api/v1/appointment-resource-slots (2026-08)
عمومی — بدون توکن. نسخهٔ عمومیِ GET /api/v1/resource/{uuid}/service-slots.
| پارامتر | توضیح |
|---|---|
resource_uuid |
الزامی |
date |
Y-m-d، الزامی. تاریخِ تقویمیِ واقعی — «2026-13-99» رد میشود |
service_item_uuids[] |
یک یا چند سرویسِ روشن؛ خالی ⇒ 422 |
durations[] که نسخهٔ پنلی میپذیرد اینجا پشتیبانی نمیشود: override مدت ابزار منشی
است و در دست بازدیدکننده یعنی ساختنِ ظرفیتِ ساختگی.
مدت، اشغال، ظرفیت و چیدمانِ پشتسرهم دقیقاً مثل نسخهٔ پنلی است
(ResourceBookingSlotService).
خروجی واقعی (بدون هدر Authorization؛ کوتاهشده):
{
"success": true,
"data": {
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"date": "2026-08-10",
"timezone": "Asia/Tehran",
"total_duration_minutes": 20,
"start_times": [
{ "start": 1786339800, "end": 1786341000, "start_time": "09:00", "end_time": "09:20" },
{ "start": 1786341000, "end": 1786342200, "start_time": "09:20", "end_time": "09:40" }
]
}
}
۴۲۲ — field: resource_uuid (ERR_VALIDATION_002، «منبع یافت نشد»): منبعِ ناموجود،
غیرفعال، یا منبعی که هیچ سرویسِ روشنی ندارد. عمداً ۴۲۲ است نه ۴۰۴، چون همان کدی است که
POST /api/v1/appointment برای منبع برمیگرداند.
۴۲۲ — field: service_item_uuids: سرویسِ ناموجود (ERR_VALIDATION_002) · سرویسی که
توگلِ آنلاینش خاموش است یا این منبع ارائهاش نمیدهد · سرویسِ بیمدت · فهرست خالی.
{
"success": false,
"data": null,
"errors": [
{ "code": "ERR_VALIDATION_001", "message": "این سرویس برای نوبتدهی آنلاین فعال نیست", "field": "service_item_uuids" }
]
}
۴۲۲ — field: date: فرمت یا تاریخِ ناموجود.
۲۰۰ با start_times: [] — روزی که منبع شیفت ندارد یا کاملاً پر است. خطا نیست.
GET /api/v1/appointment-resource-month-availability/{resourceUuid} (2026-08)
عمومی — بدون توکن. ورودیِ تقویمِ سایت؛ معادلِ منبعمحورِ
appointment-settings/month-availability/{doctorUuid}.
| پارامتر | توضیح |
|---|---|
year · month |
میلادی، همان قرارداد نسخهٔ پزشکمحور |
service_item_uuids[] |
الزامی |
سرویسها الزامیاند چون منبع اسلاتِ ثابت ندارد: «روز فعال» یعنی دستکم یک بازهٔ خالی به اندازهٔ مجموعِ مدتِ همین سرویسها. بدون آن، تقویم روزی را سبز نشان میداد که برای سرویسِ ۹۰ دقیقهای جا ندارد.
enabled_dates و disabled_dates با هم همهٔ روزهای ماهاند؛ سایت روی همین دو فهرست
تصمیم میگیرد. برخلاف نسخهٔ پزشکمحور فیلد online_booking_enabled ندارد — آن پرچم روی
برنامهٔ هفتگیِ پزشک است و منبع همتایی برایش ندارد. مصرفکنندهٔ سایت نبودش را «روشن»
تفسیر میکند.
خروجی واقعی (بدون توکن؛ فهرستها کوتاهشده):
{
"success": true,
"data": {
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"year": 2026,
"month": 9,
"total_duration_minutes": 20,
"enabled_dates": ["2026-09-01", "2026-09-02", "2026-09-05"],
"disabled_dates": ["2026-09-03", "2026-09-04", "2026-09-10"]
}
}
خطاها: همان resource_uuid و service_item_uuidsِ اندپوینت بالا، بهعلاوهٔ ۴۲۲ با
field: month روی سال یا ماهِ نامعتبر.
هزینه: پیادهسازی همان الگوی حلقهٔ روزانهٔ نسخهٔ پزشکمحور است. اندازهگیری محلی روی ماهی با ۳۰ روز: حدود ۲۷ میلیثانیه در فراخوانی گرم (اولین فراخوانی ۸۷ میلیثانیه). بهینهسازی بازهای لازم نشد.
PUT /api/v1/resource/{uuid}/categories
مجوز: appointment_settings.update.
از ۲۰۲۶-۰۸ در پنل ادمین سطحی ندارد. تب «دستهبندیها»ی صفحهٔ منبع برداشته شد؛ اندپوینت و جدول و فیلد
categoriesدر پاسخ سرِ جایشاناند، ولی هیچ صفحهای آنها را نمینویسد و نمیخواند. تنها اثر رفتاریِ این داده، ترتیبِfindEligibleاست که مصرفکنندهاش (AppointmentPlanBuilder) فقطcountوmaxمیگیرد — یعنی امروز روی هیچ خروجیای اثر ندارد. «کدام منبع این سرویس را میدهد» راResourceServiceOfferingصریح و بهصورت فیلتر جواب میدهد.
دستهٔ منبع از کاتالوگ سراسری انتخاب میشود (CatalogCategory) — همان دستههایی که سرویس
هم از آنها استفاده میکند. ساخت دسته اینجا ممکن نیست؛ فقط در «تنظیمات ← دستهبندیها»
(clinic-services.md).
جایگزینی کامل، مثل skills: {"category_uuids":[]} همه را پاک میکند.
{ "category_uuids": ["8ae755b5-5f27-404e-9b63-1b29693a9039"] }
پاسخ ۲۰۰ کلِ منبع است؛ بخش categories آن (خروجی واقعی):
{
"success": true,
"data": {
"uuid": "ce070910-7038-4f50-9f7a-1b35ec1a67f7",
"name": "اتاق ۱",
"categories": [
{ "uuid": "8ae755b5-5f27-404e-9b63-1b29693a9039", "name": "دست (doc)" }
]
}
}
۴۲۲: نبودِ category_uuids ({"code":"ERR_VALIDATION_002","message":"فیلد category_uuids الزامی است","field":"category_uuids"})
· دستهٔ محیط دیگر. uuid از بدنهٔ درخواست میآید و TenantFilter رویش اعمال نمیشود، پس محیطِ
هر دسته صریحاً با محیط منبع مقایسه میشود.
استخر منابع
گروهی از منابع که جایگزین کامل یکدیگرند: «لیزرهای آلکساندرایت»، «اتاقهای معاینه».
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
npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \
assets/admin/components/appointments/ResourceBookingModal.test.tsx \
assets/admin/components/appointments/serviceTimeline.test.ts
مسدودسازی موردی
«این بعدازظهر دستگاه سرویس دارد» — یک بازهٔ مشخص که منبع در دسترس نیست.
| مسدودسازی موردی | استثنای تقویم | |
|---|---|---|
| چیست | یک بازهٔ مشخص | تغییر الگوی تکرارشوندهٔ کاری |
| کجا | 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 | در این بازه نوبت یا رزرو موقت هست |
مسدودسازی روی بازهای که نوبت دارد ظرفیت را پس نمیگیرد: نوبت سرجایش میماند و کاربر باید اول تکلیفش را روشن کند.
شمار نوبتهای آینده
GET /api/v1/resource/{uuid} علاوه بر خودِ منبع، upcoming_appointments میدهد: تعداد
نوبتهای آینده که روی این منبع نشستهاند.
در فهرست منابع نمیآید — آنجا یک کوئری per ردیف میشد. غیرفعالکردن منبع نوبتهای ثبتشده را لغو نمیکند و فقط از جستجوی وقتِ بعدی حذفش میکند، پس این عدد هشدار است نه مانع؛ پنل هنگام برداشتن تیک «منبع فعال است» نشانش میدهد.
DELETE /api/v1/resource-block/{uuid}
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف نمیشود (422) — وگرنه
نوبت بیمار بیصدا منبعش را از دست میداد.
هر دو عمل رویداد دامنه ثبت میکنند: ResourceBlocked و ResourceReleased. ظرفیتی که
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
بشنود، منبع را برای همیشه اشغال میبیند.