# 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-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, "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} **غیرفعال — همیشه `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.