Section 5 of the design document rejects summing service durations. "Face + bikini" is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not happen twice. Seven wasted minutes times twenty appointments a day is an hour of capacity lost daily, and AppointmentController was doing exactly that plain sum. Each item now carries a solo duration and an additional duration. One item counts at its solo duration and the rest at their additional; the anchor is the item with the *largest* solo duration rather than the first one selected. Anchoring on selection order would have let the same basket cost different amounts depending on click order, so a patient could buy a shorter appointment by reordering. Largest-first is also conservative: no combination is ever under-estimated, and under-estimating pushes the next appointment on top of this one. additional_duration_minutes stays NULL by default and the entity reads NULL as "same as solo", so every existing service keeps behaving exactly as before — the 236 appointment-domain tests pass unchanged. The old duration_minutes column is kept and written in step rather than renamed, because other consumers still read it. ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line change task 00 predicted when it deliberately preserved the naive sum. Selection rules are data, not policy: min/max per group is a number, and "bikini does not combine with full body" is a relation. Putting either in a rules engine means several rules per service and nobody able to explain a rejection. Validation returns *all* errors at once rather than the first, since a user with three problems should not make three round trips. Prerequisite cycles are rejected at write time — storing both "A requires B" and "B requires A" would make every selection permanently invalid. Named CatalogCategory, not ServiceCategory: that name is already an insurance enum (outpatient/inpatient) living on ServiceItem itself, so the two would have collided in the same file's imports. Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key that may not exist, which warns instead of yielding null. 1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without this change (verified by stashing). Slot-mode frozen contract green. The admin UI tab for groups and relations is not built; the checklist records it as outstanding with a target. The backend is complete and POST /service-selection/validate is consumable without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23 KiB
Clinic Services API
مدیریت بخشها و سرویسهای کلینیک/مطب.
نیاز به پنل: Basic یا بالاتر (ERR_SUBSCRIPTION_REQUIRED اگر نداشت)
دسترسیِ منشی: همهٔ endpointها با مجوزِ منشیِ services گِیت میشوند (view/create/update/delete)؛ نبودِ مجوز → 403 ERR_FORBIDDEN_001 و این گیت پیش از گیتِ اشتراک اجرا میشود. owner از محیطِ فعالِ منشی با SecretaryAccessChecker::resolveOwnerEntity حل میشود (چون EntityContextResolver منشی را مالک نمیشناسد). نقشهای owner/پزشک/ادمین بدون تغییر عبور میکنند. جزئیات: secretary.md.
GET /api/v1/service-categories
لیست انواع خدمت (سرپایی/بستری/…). تنها منبع این لیست برای کلاینتها؛ افزودن نوع تازه در
بکاند یک case است و بدون تغییر فرانت اینجا ظاهر میشود. درصد پوشش بیمه به ازای همین
نوعها تعیین میشود (insurance.md).
Permission: IS_AUTHENTICATED_FULLY
Response 200:
{
"success": true,
"data": [
{ "key": "outpatient", "label": "خدمات سرپایی" },
{ "key": "inpatient", "label": "خدمات بستری" }
]
}
GET /api/v1/service-sections
لیست بخشهای سرویس entity جاری.
Permission: IS_AUTHENTICATED_FULLY + پنل Basic+
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "clinic",
"entity_id": 5,
"name": "آزمایشگاه",
"active": true,
"items_count": 10,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
items_countتعداد سرویسهای همان بخش است (فقط در این endpoint لیستی برگردانده میشود).
POST /api/v1/service-section
ایجاد بخش جدید.
Permission: IS_AUTHENTICATED_FULLY + پنل Basic+
Request Body:
{ "name": "رادیولوژی" }
Response 201: ServiceSection object
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_VALIDATION_001 | 422 | name خالی است |
PATCH /api/v1/service-section/{uuid}
ویرایش بخش.
Permission: owner یا ROLE_ADMIN
{ "name": "رادیولوژی دیجیتال", "active": true }
DELETE /api/v1/service-section/{uuid}
غیرفعال — همیشه 409 برمیگرداند. حذفِ بخش، سرویسهای زیرمجموعه را هم پاک میکرد و
سوابق پرداخت/فاکتور به همان سرویسها ارجاع دارند. برای برداشتنِ بخش از پذیرشِ جدید،
آن را غیرفعال کنید: PATCH /api/v1/service-section/{uuid} با {"active": false}.
پاسخ: 409 ERR_SERVICE_ITEM_IN_USE — «حذف بخش ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید.»
GET /api/v1/service-items
همهی سرویسهای owner در همهی بخشها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایهی ServiceItem::toArray)، مرتب بر نام.
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابلتعیین نیست (ادمین، منشی، نماینده، کاربر عادی) |
GET /api/v1/service-items/{sectionUuid}
لیست سرویسهای یک بخش.
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"section_uuid": "...",
"staff_uuid": "...",
"staff_name": "علی محمدی",
"staff": { "uuid": "...", "full_name": "علی محمدی" },
"staff_members": [
{ "uuid": "...", "full_name": "علی محمدی" },
{ "uuid": "...", "full_name": "سحر رحمانی" }
],
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"active": true,
"insurance_covered": false,
"service_category": "outpatient",
"service_category_label": "خدمات سرپایی",
"duration_minutes": 50,
"bookable": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
GET /api/v1/service-item/{uuid}
یک سرویس مشخص — پشتیبان صفحهی اختصاصی «جزئیات سرویس» (/admin/clinic-services/{uuid}) که باید با
refresh مستقیم هم کار کند، بنابراین فیلترکردن سمت کلاینت از فهرست کامل کافی نبود.
Permission: IS_AUTHENTICATED_FULLY + پنل Basic+ · فقط سرویسهای همان مطب/کلینیک
(ServiceItem→section→entity_type/entity_id).
Response 200: یک ServiceItem object (ساختار یکسان با آیتمهای فهرست، شامل section_uuid،
section_name، staff_members، created_at و updated_at):
{
"success": true,
"data": {
"uuid": "...",
"section_uuid": "...",
"section_name": "تزریقات",
"name": "سرم ۵۰۰cc",
"price_rials": 850000,
"active": true,
"insurance_covered": true,
"duration_minutes": 30,
"bookable": true,
"staff": { "uuid": "...", "full_name": "مریم امینی" },
"staff_members": [{ "uuid": "...", "full_name": "مریم امینی" }],
"inventory_package_uuid": "...",
"inventory_package_title": "پکیج سرم",
"consumables": [
{ "item_uuid": "...", "name": "گاز استریل", "unit": "عدد", "price": 50000, "stock": 100, "amount": 2 }
],
"created_at": 1718000000,
"updated_at": 1718000000
}
}
خطاها: 404 ERR_SERVICE_NOT_FOUND — هم برای uuid ناموجود و هم برای سرویس متعلق به tenant دیگر
(وجود سرویس نباید لو برود) · 401 بدون احراز هویت.
section_name،inventory_package_id،inventory_package_uuid،inventory_package_titleوconsumablesدر همهی پاسخهای سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه میشوند (نهtoArray()) و پکیجها با یک کوئری batch واکشی میشوند تا فهرست سرویسها به N+1 نیفتد.
GET /api/v1/service-item/{uuid}/audit-logs
تاریخچهی تغییرات یک خدمت — تازهترین رویداد اول، حداکثر ۱۰۰ ردیف.
Permission: IS_AUTHENTICATED_FULLY + پنل Basic+ · فقط سرویسهای همان مطب/کلینیک.
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"field": "price_rials",
"operation": "update",
"old_value": "700000",
"new_value": "850000",
"actor_name": "دکتر رضایی",
"created_at": 1718000000
}
]
}
فیلدهای رهگیریشده (ServiceItemAuditService::TRACKED): name، price_rials، active،
duration_minutes، bookable، insurance_covered، inventory_package، consumables.
- هر فیلدِ تغییریافته یک ردیف جدا میسازد؛ فیلد بدون تغییر ردیف نمیسازد.
operation:create(هنگام ساخت خدمت، فقط یک ردیف روی فیلدname) یاupdate.- مقادیر بهصورت رشته ذخیره میشوند: بولینها
"1"/"0"، مبالغ ریال، وnullیعنی «بدون مقدار». ترجمهی نمایشی سمت پنل انجام میشود (FIELD_LABELSوauditValueدرServiceDetailPage.tsx). - جدول
service_item_audit_logsباON DELETE CASCADEبهservice_itemsوصل است.
خطاها: 404 ERR_SERVICE_NOT_FOUND · 401.
POST /api/v1/service-item
ایجاد سرویس جدید.
Permission: IS_AUTHENTICATED_FULLY + پنل Basic+
Request Body:
{
"section_uuid": "...",
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"staff_uuid": "...",
"insurance_covered": true,
"service_category": "outpatient",
"duration_minutes": 50,
"bookable": true
}
| فیلد | نوع | الزامی |
|---|---|---|
| section_uuid | UUID | ✅ |
| name | string | ✅ |
| price_rials | integer | ❌ (پیشفرض 0) — «قیمت پایه» |
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر staff_uuids نباشد استفاده میشود) |
| insurance_covered | boolean | ❌ (پیشفرض false) — deprecated برای نوشتن. پنل ادمین دیگر این فیلد را نمیفرستد؛ مقدارش بهصورت خودکار از ردیفهای پوشش بیمه همگام میشود (به insurance.md نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
| service_category | string | ❌ (پیشفرض outpatient) — «نوع خدمت»؛ یکی از مقادیر GET /api/v1/service-categories. درصد پوشش بیمهٔ این خدمت از همین نوع resolve میشود. مقدار نامعتبر → 422 ERR_VALIDATION_001 با فیلد service_category |
| duration_minutes | integer|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (""/null = بدون مقدار) |
| bookable | boolean | ❌ (پیشفرض false) — «نمایش در نوبتدهی». فقط سرویسهای bookable=true در حالت نوبتدهی سرویسی قابلانتخاباند |
| inventory_package_uuid | UUID|null | ❌ — پکیج کالای مصرفی این خدمت (inventory.md). null/"" یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه 422 ERR_VALIDATION_001 با فیلد inventory_package_uuid |
| consumables | array|null | ❌ — کالاهای تکی این خدمت: [{ "item_uuid": "…", "amount": 2 }]. مکمل پکیج است، نه جایگزین آن — یک خدمت میتواند همزمان پکیج و کالای تکی داشته باشد. ارسال این فیلد کل فهرست را جایگزین میکند ([] = حذف همه). هر کالا باید متعلق به همان مطب/کلینیک باشد وگرنه 422 ERR_VALIDATION_001 با فیلد consumables. amount حداقل ۱ است |
bookableوservice_categoryدرPATCH /api/v1/service-item/{uuid}هم به همین شکل پذیرفته میشوند؛ تغییر نوع خدمت در audit-log با برچسب «نوع خدمت» ثبت میشود.
Response 201: ServiceItem object (شامل insurance_covered)
قیمت واحد: هنگام ساخت سرویس، یک تعرفه برای سال جاری با همان
price_rialsبهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
PATCH /api/v1/service-item/{uuid}
ویرایش سرویس.
{
"name": "رادیوگرافی دیجیتال",
"price_rials": 600000,
"staff_uuid": null,
"active": false,
"insurance_covered": true,
"duration_minutes": 30
}
فیلد
duration_minutes(زمان متوسط، دقیقه) در پاسخِtoArrayو در ساخت/ویرایش پشتیبانی میشود؛""/nullآن را پاک میکند.
پرسنل چندنفره: یک سرویس میتواند چند پرسنل داشته باشد.
staff_uuids(آرایه) ترجیح داده میشود؛ در نبود آن،staff_uuidتکنفره بهصورت backward-compatible پذیرفته میشود. پاسخ همیشهstaff_members[](کامل) وstaff/staff_uuid/staff_name(نفر اول، برای سازگاری) را برمیگرداند. هر پرسنل باید متعلق به همان tenant (entity_type/entity_id) باشد؛ در غیر این صورت →422 ERR_VALIDATION_001(field: staff_uuids). همین قید رویPOST /service-itemنیز اعمال میشود.
DELETE /api/v1/service-item/{uuid}
غیرفعال — همیشه 409 برمیگرداند. سرویسها هرگز حذف نمیشوند: نوبتها، جلسات،
فاکتورها و سوابق پرداخت به سرویس ارجاع دارند و حذف آنها را ناسازگار میکرد. برای
برداشتنِ سرویس از پذیرشِ جدید، آن را غیرفعال کنید: PATCH /api/v1/service-item/{uuid}
با {"active": false} — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمیشود ولی سوابق حفظ میمانند.
{
"success": false,
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید." }]
}
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_SERVICE_ITEM_IN_USE | 409 | حذف مجاز نیست — فقط غیرفعالکردن ممکن است |
تعرفهی نسخهدار سالانه (Tariff) — فاز ۳ سیستم صورتحساب
هر خدمت میتواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفهی سالی ثبت نشود، به price_rials خود خدمت fallback میشود (TariffService::resolvePrice). سال جاری شمسی سمت سرور با IntlDateFormatter (تقویم persian) محاسبه میشود.
GET /api/v1/service-items/{uuid}/tariffs
لیست تعرفههای یک خدمت + قیمت پیشفرض + سال جاری.
Permission: IS_AUTHENTICATED_FULLY (مالک خدمت)
{
"success": true,
"data": {
"current_year": 1405,
"default_price_rials": 500000,
"data": [
{ "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
{ "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
]
}
}
PUT /api/v1/service-items/{uuid}/tariffs/{year}
ثبت/بهروزرسانی تعرفهی یک سال (upsert). year بین ۱۳۹۰ تا ۱۵۰۰.
Body:
{ "price_rials": 600000 }
Response 200: { success, data: { …tariff } }
اگر
yearبرابر سال جاری باشد،ServiceItem.price_rialsهم با همین مقدار همگام میشود (قیمت واحد). تعرفهی سالهای دیگر فقط برای محاسبهی صورتحساب همان سال (TariffService::resolvePrice) بهکار میرود و قیمت پایهی سرویس را تغییر نمیدهد. همچنینPATCH /service-item/{uuid}با تغییرprice_rials، تعرفهی سال جاری را upsert میکند.
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
Owner resolution (2026-07)
Every endpoint in this file resolves its owner through App\Shared\Context\EntityContextResolver
instead of reading the caller's role directly. Precedence:
- an explicit
clinic_uuidon the request (query string, or body onPOST/PATCH/PUT) — 403 if the caller may not act in that clinic; - the caller's stored active context (
user_active_context); - their role.
This fixes a user who is both a doctor and a clinic owner: they used to always resolve as doctor
and could never reach their own clinic's services.
GET /api/v1/service-items?clinic_uuid=… therefore returns that clinic's services rather than the
caller's personal ones.
TODO:
Inventory,Patient,Staff,Billing,Insurance,Subscription,TagandSmscontrollers still carry their own privateresolveEntity()copy with the old role-first logic. They should be migrated toEntityContextResolvertoo.
کاتالوگ نسخهٔ ۲ — دسته، گروه انتخاب، رابطه و دو نوع زمان
اندپوینتهای بالا دستنخوردهاند؛ آنچه در ادامه میآید افزوده است.
چرا جمع ساده رد شد
بند ۵ مستند: «صورت + بیکینی» ۱۵+۱۲=۲۷ دقیقه نیست، ۱۵+۸=۲۳ است — آمادهسازی و استقرار بیمار دو بار انجام نمیشود. هفت دقیقهٔ هدررفته ضرب در روزی ۲۰ نوبت یعنی یک ساعت ظرفیت در روز.
پس هر آیتم دو زمان دارد:
| ستون | یعنی |
|---|---|
solo_duration_minutes |
وقتی این آیتم تنها انجام شود |
additional_duration_minutes |
وقتی کنار آیتم دیگری در همان نوبت باشد |
فرمول: یک آیتم با مدت تنها حساب میشود و بقیه با مدت اضافه. لنگر آنکه بزرگترین مدت تنها را دارد — نه «اولین انتخابشده»، چون آنوقت همان سبد با ترتیب دیگر مدت دیگری میگرفت و بیمار با جابهجا کردن کلیکها وقت کوتاهتر میخرید. انتخاب بزرگترین، محافظهکارانه هم هست: هیچ ترکیبی کمتخمین نمیشود.
additional تهی یعنی «همان مدت تنها» — پس دادهٔ موجود دقیقاً مثل قبل (جمع ساده)
حساب میشود و این تغییر افزایشی است. duration_minutes قدیمی حذف نشده و همگام
نوشته میشود.
POST /api/v1/service-selection/validate
مهمترین اندپوینت این بخش؛ سایت عمومی و پنل هر دو پیش از مرحلهٔ انتخاب زمان صدایش میزنند.
{
"item_uuids": ["…صورت", "…بیکینی"],
"service_uuid": "…لیزر", // اختیاری — گروههای کدام سرویس سنجیده شوند
"branch_uuid": "…شعبه" // اختیاری — قیمت/مدت اختصاصی شعبه اعمال شود
}
۲۰۰:
{
"success": true,
"data": {
"valid": true,
"errors": [],
"total_duration_minutes": 23,
"total_price_rials": 800000,
"breakdown": [
{ "item_uuid": "…", "item_name": "صورت", "counted_as": "solo", "minutes": 15, "price_rials": 500000 },
{ "item_uuid": "…", "item_name": "بیکینی", "counted_as": "additional", "minutes": 8, "price_rials": 300000 }
]
}
}
breakdown هست تا UI بتواند نشان دهد چرا جمع با انتظار کاربر فرق دارد.
همهٔ خطاها با هم برمیگردند، نه اولی — کاربری که سه مشکل دارد نباید سه بار رفتوبرگشت کند.
code |
کِی |
|---|---|
min_select |
از گروهی با حداقلِ n، کمتر انتخاب شده |
max_select |
از گروهی با سقفِ n، بیشتر انتخاب شده |
incompatible |
دو آیتم ناسازگار با هم انتخاب شدهاند (یک جفت = یک خطا، نه دو) |
missing_prerequisite |
آیتمی انتخاب شده که پیشنیازش نیست |
⚠️ آیتم محیط دیگر ۴۰۴ میدهد نه ۴۲۲ — وجودش نباید لو برود.
گروه انتخاب
GET/POST /api/v1/service-item/{uuid}/groups · PATCH/DELETE /api/v1/item-group/{uuid} ·
PUT /api/v1/item-group/{uuid}/items
| فیلد | یعنی |
|---|---|
min_select |
0 یعنی گروه اختیاری |
max_select |
null یعنی نامحدود — نه صفر |
«حتماً یک سطح انرژی، فقط یکی» = min=1, max=1. این یک عدد است نه یک قانون؛ سپردنش به
موتور قوانین یعنی هر سرویس چند قانون و هیچکس نمیفهمد چرا انتخابش رد شد.
رابطهٔ آیتمها
PUT /api/v1/service-item/{uuid}/relations — جایگزینی کامل.
{ "relations": [{ "related_item_uuid": "…", "type": "incompatible_with" }] }
type ∈ incompatible_with | requires.
- ناسازگاری متقارن است و فقط وقتی خطاست که هر دو انتخاب شده باشند.
- پیشنیاز جهتدار است.
- حلقهٔ پیشنیاز هنگام ثبت
422میگیرد، نه در اعتبارسنجی انتخاب: «الف نیازمند ب» و «ب نیازمند الف» اگر هر دو ذخیره میشدند، هیچ انتخابی هرگز معتبر نمیشد.
قیمت و مدت اختصاصی شعبه
PUT /api/v1/service-item/{uuid}/branch-overrides — جایگزینی کامل.
{ "overrides": [{ "address_uuid": "…", "price_rials": 900000, "solo_duration_minutes": 25 }] }
هر فیلد تهیپذیر است و null یعنی «همان مقدار خودِ سرویس» — نه صفر.
override فقط وقتی اعمال میشود که branch_uuid به validate داده شود.
دستهٔ درختی
GET /api/v1/service-categories/tree · POST/PATCH/DELETE /api/v1/service-category[/{uuid}]
نامش در کد CatalogCategory است، نه ServiceCategory: آن نام از قبل یک enum بیمهای
است (outpatient/inpatient) که روی خودِ ServiceItem هم نشسته. با ServiceSection هم
فرق دارد — آن «بخش کلینیک» است، این تاکسونومی کاتالوگ. عمق حداکثر ۴ سطح.
تستها
ddev exec php bin/phpunit tests/ClinicService # ۵۲ تست