Files
clinicpro/docs/api/clinic-services.md
T
hamed b3a5cda808 Refactor insurance share calculation logic in PatientService
- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator.
- Introduced new fields in PatientSession to store breakdown of insurance shares and patient share.
- Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard.
- Added migration to backfill existing sessions with appropriate values for the new fields.
- Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total.
- Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
2026-07-18 22:56:46 +03:30

348 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Clinic Services API
مدیریت بخش‌ها و سرویس‌های کلینیک/مطب.
**نیاز به پنل:** Basic یا بالاتر (`ERR_SUBSCRIPTION_REQUIRED` اگر نداشت)
---
## 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}
حذف بخش (cascade — همه ServiceItem های آن حذف می‌شوند).
**Permission:** owner
---
## GET /api/v1/service-items
همه‌ی سرویس‌های owner در همه‌ی بخش‌ها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایه‌ی `ServiceItem::toArray`)، مرتب بر نام.
## 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,
"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,
"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 هنوز آن را می‌پذیرد تا کلاینت‌های قدیمی نشکنند، ولی ذخیره‌ی پوشش بعداً آن را بازنویسی می‌کند |
| 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` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته می‌شود.
**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}
حذف سرویس.
اگر سرویس در پرونده بیماری استفاده شده باشد، خطا برمی‌گرداند:
```json
{
"success": false,
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "این سرویس در پرونده بیمار ثبت شده است" }]
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| 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.