Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
381 lines
17 KiB
Markdown
381 lines
17 KiB
Markdown
# Clinic Services API
|
||
|
||
مدیریت بخشها و سرویسهای کلینیک/مطب.
|
||
|
||
**نیاز به پنل:** Basic یا بالاتر (`ERR_SUBSCRIPTION_REQUIRED` اگر نداشت)
|
||
|
||
**دسترسیِ منشی:** همهٔ endpointها با مجوزِ منشیِ `services` گِیت میشوند (`view`/`create`/`update`/`delete`)؛ نبودِ مجوز → `403 ERR_FORBIDDEN_001` و این گیت **پیش از** گیتِ اشتراک اجرا میشود. owner از محیطِ فعالِ منشی با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود (چون `EntityContextResolver` منشی را مالک نمیشناسد). نقشهای owner/پزشک/ادمین بدون تغییر عبور میکنند. جزئیات: [secretary.md](secretary.md).
|
||
|
||
---
|
||
|
||
## GET /api/v1/service-categories
|
||
|
||
لیست انواع خدمت (سرپایی/بستری/…). **تنها منبع** این لیست برای کلاینتها؛ افزودن نوع تازه در
|
||
بکاند یک `case` است و بدون تغییر فرانت اینجا ظاهر میشود. درصد پوشش بیمه به ازای همین
|
||
نوعها تعیین میشود ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)).
|
||
|
||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{ "key": "outpatient", "label": "خدمات سرپایی" },
|
||
{ "key": "inpatient", "label": "خدمات بستری" }
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/v1/service-sections
|
||
|
||
لیست بخشهای سرویس entity جاری.
|
||
|
||
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"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:**
|
||
```json
|
||
{ "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
|
||
|
||
```json
|
||
{ "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:**
|
||
```json
|
||
{
|
||
"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`):
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
```json
|
||
{
|
||
"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:**
|
||
```json
|
||
{
|
||
"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](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
|
||
| service_category | string | ❌ (پیشفرض `outpatient`) — «نوع خدمت»؛ یکی از مقادیر [`GET /api/v1/service-categories`](#get-apiv1service-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](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}
|
||
|
||
ویرایش سرویس.
|
||
|
||
```json
|
||
{
|
||
"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}` — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمیشود ولی سوابق حفظ میمانند.
|
||
|
||
```json
|
||
{
|
||
"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` (مالک خدمت)
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
```json
|
||
{ "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:
|
||
|
||
1. an explicit **`clinic_uuid`** on the request (query string, or body on `POST`/`PATCH`/`PUT`) —
|
||
403 if the caller may not act in that clinic;
|
||
2. the caller's stored active context (`user_active_context`);
|
||
3. 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`, `Tag` and `Sms`
|
||
> controllers still carry their own private `resolveEntity()` copy with the old role-first logic.
|
||
> They should be migrated to `EntityContextResolver` too.
|