Both permission forms in the admin panel can now render from the backend registry instead of their own hardcoded lists. Resources come back as an array so display order is part of the contract, each carrying its Persian label, its actions, and the clinic_only flag that used to live in the frontend. contextPermissions() normalizes the no-row branch through the registry too, so a doctor whose permission row was never provisioned sees the same shape as one who has it. Two existing assertions compared the API response against DEFAULT_PERMISSIONS by identity. The values are unchanged; only key order moved to the registry's, so both now compare through PermissionCatalog::merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
717 lines
31 KiB
Markdown
717 lines
31 KiB
Markdown
# Secretary API
|
||
|
||
> **Prefix:** `/api/v1/secretary`, `/api/v1/secretaries`
|
||
|
||
## مدل Scope
|
||
|
||
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیطهای مختلف جلوگیری میکند:
|
||
|
||
| Scope | `owner_type` | تعریفکننده | دسترسی |
|
||
| -------- | ------------ | ----------- | ------------------------------- |
|
||
| مطب شخصی | `doctor` | خود پزشک | فقط نوبتها و دادههای مطب شخصی |
|
||
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبتها و دادههای کلینیک |
|
||
|
||
- یک منشی میتواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
|
||
- منشی کلینیک میتواند به چند دکتر در همان کلینیک متصل باشد
|
||
- **یک منشی میتواند به همان دکتر در چند کلینیک متفاوت تخصیص یابد** (یک ردیف به ازای هر کلینیک). تا پیش از این، کلید یکتا فقط `(doctor_id, secretary_id, owner_type)` بود و کلینیکِ دوم را تکراری میشمرد؛ حالا خودِ محیط هم بخشی از هویت رابطه است
|
||
- scope فعال در runtime از جدول `user_active_context` خوانده میشود — `db_uuid` بههمراه `db_type` که میگوید uuid مالِ پزشک است یا کلینیک
|
||
- **محدودسازی به پزشکانِ تخصیصیافته:** منشیِ کلینیک فقط نوبتهای پزشکانی را میبیند/رزرو میکند که واقعاً به او تخصیص داده شدهاند — نه همهی پزشکان کلینیک. لیست نوبت (`GET /api/v1/my/appointments`) با `a.doctor IN (پزشکانِ تخصیصیافته)` فیلتر میشود و گیت رزرو (`POST /api/v1/my/appointment`) رابطهی فعالِ همان (منشی، کلینیک، پزشک) را چک میکند. permission رزرو از همان ردیفِ پزشک خوانده میشود
|
||
|
||
Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor.
|
||
|
||
---
|
||
|
||
## POST `/api/v1/secretary`
|
||
|
||
Create a secretary for a doctor.
|
||
|
||
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
|
||
|
||
### Request Body (`application/json`)
|
||
|
||
```json
|
||
{
|
||
"doctor_uuid": "550e8400-...",
|
||
"mobile_number": "09123456789",
|
||
"name": "سارا احمدی",
|
||
"national_code": "1234567890",
|
||
"address": "یزد، خیابان تست",
|
||
"password": "secretaryPass123",
|
||
"permissions": {
|
||
"version": 1,
|
||
"resources": {
|
||
"appointments": {
|
||
"view": true,
|
||
"create": true,
|
||
"cancel": false,
|
||
"update_status": true
|
||
},
|
||
"patients": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"payments": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"insurances": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"addresses": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"clinic_info": { "view": true, "update": false },
|
||
"inventory": {
|
||
"view": false,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"tags": {
|
||
"view": false,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"services": {
|
||
"view": false,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"staff": { "view": false, "create": false, "update": false, "delete": false },
|
||
"discounts": { "view": false, "create": false, "update": false, "delete": false },
|
||
"sms": { "view": false, "create": false, "update": false, "delete": false },
|
||
"appointment_settings": { "view": false, "update": false },
|
||
"clinic_doctors": { "view": false, "create": false, "update": false, "delete": false },
|
||
"subscription": { "view": false, "create": false }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
| --------------- | ------------- | -------- | -------------------------------------------- |
|
||
| `doctor_uuid` | string (UUID) | ✅\* | Single doctor to assign (legacy/doctor flow) |
|
||
| `doctor_uuids` | string[] (UUID) | ✅\* | **Clinic only** — assign one secretary to several clinic doctors at once. When present (non-empty) and caller is `ROLE_CLINIC`, this multi-doctor path is used instead of `doctor_uuid` |
|
||
| `mobile_number` | string | ✅ | Secretary's login mobile. Persian/Arabic digits are accepted and normalized server-side — see [README → Persian digit normalization](README.md#persian-digit-normalization-global) |
|
||
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
|
||
| `national_code` | string | ❌ | کد ملی منشی (nullable). Persian/Arabic digits accepted and normalized |
|
||
| `address` | string | ❌ | آدرس منشی (nullable) |
|
||
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
|
||
| `permissions` | object | ❌ | Permission set (see structure below) |
|
||
|
||
\* یکی از `doctor_uuid` (تکپزشکی) یا `doctor_uuids` (چندپزشکیِ کلینیک) الزامی است.
|
||
|
||
**پاسخِ حالت چندپزشکی (`doctor_uuids` + `ROLE_CLINIC`) — `201`:**
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"secretary_uuid": "550e8400-...",
|
||
"created": [ { "uuid": "...", "secretary_uuid": "...", "doctor_uuid": "...", "...": "..." } ],
|
||
"skipped_duplicate": [],
|
||
"skipped_limit": [],
|
||
"skipped_not_in_clinic": []
|
||
}
|
||
}
|
||
```
|
||
|
||
- `created`: ردیفهای تازهساخته/فعالشده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیهی پزشکان ادامه مییابند.
|
||
|
||
**Permissions Structure:**
|
||
|
||
مجموعهٔ منابع را دیگر این فایل تعیین نمیکند: منبعِ واحد `App\Shared\Security\PermissionCatalog` است و از `GET /api/v1/permission-catalog` هم خوانده میشود — [permission.md](permission.md). فهرستِ فعلی: `appointments`, `patients`, `treatment`, `payments`, `insurances`, `addresses`, `clinic_info`, `services`, `inventory`, `staff`, `tags`, `discounts`, `sms`, `appointment_settings`, `resources`, `clinic_doctors`, `subscription`.
|
||
|
||
منبعِ `subscription` فقط `view/create` دارد؛ `clinic_info`, `appointment_settings` و `treatment` فقط `view/update`. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو) و در کاتالوگ با `clinic_only: true` علامت خورده.
|
||
|
||
`mergePermissions` هر منبع/اکشن ارسالشده را deep-merge میکند و **هر دو شکلِ ورودی** را میپذیرد: با envelope (`{version, resources:{…}}`) و نقشهٔ تخت (`{patients:{…}}`). تا پیش از این فقط شکلِ اول خوانده میشد و صفحهٔ ادمین که تخت میفرستد بیصدا بیاثر بود. منبع یا اکشنِ خارج از رجیستری بیصدا کنار گذاشته میشود؛ بقیهٔ کلیدهای همان درخواست اعمال میشوند.
|
||
|
||
پیشفرضها (`DEFAULT_PERMISSIONS`) سیاستِ نقشِ منشیاند، نه ساختار: `appointments`, `patients`, `treatment`, `payments`, `insurances`, `addresses`, `clinic_info` با `view` روشن؛ بقیه default-deny. منبعی که بعد از ساختِ یک ردیف به رجیستری اضافه شود، هنگام خواندن **پیشفرضِ نقش** را میگیرد نه `false` — پس نیازی به migration داده نیست.
|
||
|
||
**اعمال (enforcement):** همهٔ منابع در بکاند enforce میشوند، نه فقط `appointments`. منبعِ حقیقت، ستون JSON `permission` روی ردیفِ فعالِ `DoctorSecretary` در محیطِ فعالِ کاربر (`UserActiveContext.db_uuid`) است؛ نقطهٔ مرکزی `App\Secretary\Security\SecretaryAccessChecker` (`can` / `canOrNonSecretary` / `denyUnlessGranted`). نبودِ مجوز → `403 ERR_FORBIDDEN_001`. نقشه:
|
||
|
||
| Resource | Enforced in | Action → endpoint |
|
||
| --- | --- | --- |
|
||
| `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status |
|
||
| `patients` | `PatientController` (خواندنها via `scope()` → بدون `view` هیچ پروندهای؛ افزودن/ویرایشِ زیرآیتمها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete |
|
||
| `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیفپول + پرداختِ جلسه) | view/create/update/delete |
|
||
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
|
||
| `inventory` | `InventoryController` (items + packages) | view/create/update/delete |
|
||
| `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتنها با `tags.*`) | view/create/update/delete |
|
||
| `services` | `ClinicServiceController` (sections + items). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||
| `staff` | `StaffController` (resolveEntity منشیآگاه) | view/create/update/delete |
|
||
| `discounts` | `DiscountController` (CRUD؛ `suggestions` جزو flowِ جلسه است و با discounts گِیت نمیشود) | view/create/update/delete |
|
||
| `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update |
|
||
| `appointment_settings` | `AppointmentSettingsController::denyDoctorAccess` → `SecretaryAccessChecker::canForDoctor` (اسکوپِ پزشکِ تخصیصیافته + توگل). `clinic_uuid` برای محیطِ کلینیک لازم است | view/update |
|
||
| `clinic_doctors` (فقط کلینیک) | `ClinicController::detachDoctor` (delete)، `ClinicDoctorPermissionController` (view/update)، `ClinicInvitationController` (create/view/update/delete) via `SecretaryAccessChecker::canForClinic` | view/create/update/delete |
|
||
| `subscription` | `SubscriptionController::my` (view) و `trial` (create)، `PaymentController::initiateSubscription` (create). resolveEntity از قبل منشیآگاه است | view/create |
|
||
|
||
نقشهای غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور میکنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همهچیز `403`.
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"resources": {
|
||
"appointments": {
|
||
"view": true, // Can view appointments list
|
||
"create": true, // Can book appointments
|
||
"cancel": false, // Can cancel appointments
|
||
"update_status": true // Can mark as completed/no_show
|
||
},
|
||
"patients": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"payments": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"insurances": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"addresses": {
|
||
"view": true,
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"clinic_info": {
|
||
"view": true,
|
||
"update": false
|
||
},
|
||
"inventory": {
|
||
"view": false, // انبار: مشاهده
|
||
"create": false, // ایجاد کالا/بسته
|
||
"update": false,
|
||
"delete": false
|
||
},
|
||
"tags": {
|
||
"view": false, // تگها؛ لیست با tags.view یا patients.view
|
||
"create": false,
|
||
"update": false,
|
||
"delete": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response `201`
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "sec-uuid-...",
|
||
"user_name": "علی محمدی",
|
||
"mobile_number": "09123456789",
|
||
"doctor_name": "احمد رضایی",
|
||
"doctor_uuid": "...",
|
||
"owner_type": "doctor",
|
||
"clinic_uuid": null,
|
||
"is_active": true,
|
||
"national_code": "1234567890",
|
||
"address": "یزد، خیابان تست",
|
||
"permissions": { ... },
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
**مقادیر `owner_type`:**
|
||
| مقدار | معنی |
|
||
|-------|------|
|
||
| `doctor` | منشی توسط خود دکتر تعریف شده — فقط مطب شخصی |
|
||
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
|
||
|
||
**پیامک خوشآمد:** پس از ساخت موفق منشی، یک پیامک بهصورت async به `mobile_number` منشی ارسال میشود (تگ `secretary`). متن از قالب ویرایشپذیر DB میآید (fallback به پیشفرض) و placeholderهای `{owner}` (نام دکتر یا کلینیک بسته به `owner_type`)، `{username}` (موبایل منشی) و `{link}` (لینک ورود) را جایگزین میکند. ویرایش متن از `PATCH /api/v1/admin/sms/messages/secretary` — رجوع به [sms.md](sms.md).
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | ------------------------------------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor **in this same environment** — همان منشی برای همان پزشک در کلینیکِ دیگر ۴۰۹ نمیگیرد |
|
||
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/secretary/{uuid}`
|
||
|
||
Get secretary detail.
|
||
|
||
**Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
| ------ | ------------- | -------------- |
|
||
| `uuid` | string (UUID) | Secretary UUID |
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "...",
|
||
"mobile_number": "09123456789",
|
||
"active": true,
|
||
"permissions": { ... },
|
||
"doctor": { "uuid": "...", "title": "علی احمدی" },
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | ------------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/secretary/{uuid}`
|
||
|
||
Update secretary active status, profile fields (name/national_code/address), or permissions. تمام فیلدها اختیاریاند و فقط موارد ارسالشده اعمال میشوند.
|
||
|
||
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
||
|
||
### Request Body (`application/json`)
|
||
|
||
```json
|
||
{
|
||
"active": false,
|
||
"name": "نام جدید",
|
||
"national_code": "9999999999",
|
||
"address": "آدرس جدید",
|
||
"permissions": {
|
||
"version": 1,
|
||
"resources": {
|
||
"appointments": {
|
||
"view": true,
|
||
"create": false,
|
||
"cancel": false,
|
||
"update_status": false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
| --------------- | ------- | -------- | -------------------------------------------- |
|
||
| `active` | boolean | ❌ | Enable/disable secretary |
|
||
| `name` | string | ❌ | بهروزرسانی نام کامل منشی (`user_name`) |
|
||
| `national_code` | string | ❌ | بهروزرسانی کد ملی (nullable) |
|
||
| `address` | string | ❌ | بهروزرسانی آدرس (nullable) |
|
||
| `permissions` | object | ❌ | New permissions object (deep-merged) |
|
||
|
||
### Response `200`
|
||
|
||
Updated secretary object.
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | --------------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/secretary/{uuid}`
|
||
|
||
Delete a secretary.
|
||
|
||
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{ "success": true, "data": { "message": "منشی حذف شد" } }
|
||
```
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | --------------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/secretaries/{doctorUuid}`
|
||
|
||
Get all secretaries for a specific doctor.
|
||
|
||
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
| ------------ | ------------- | ----------- |
|
||
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"user_name": "علی محمدی",
|
||
"mobile_number": "09...",
|
||
"doctor_name": "احمد رضایی",
|
||
"doctor_uuid": "...",
|
||
"is_active": true,
|
||
"permissions": { ... },
|
||
"created_at": 1717000000
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | ---------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/secretaries/clinic/{clinicUuid}`
|
||
|
||
Get all secretaries across **all doctors** of a clinic.
|
||
|
||
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
| ------------ | ------------- | ----------- |
|
||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"secretary_uuid": "...",
|
||
"user_name": "علی محمدی",
|
||
"mobile_number": "09...",
|
||
"doctor_name": "احمد رضایی",
|
||
"doctor_uuid": "...",
|
||
"is_active": true,
|
||
"permissions": { ... },
|
||
"created_at": 1717000000
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Notes
|
||
|
||
- این endpoint فقط منشی های را برمیگرداند که با `owner_type='clinic'` تعریف شدهاند
|
||
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
|
||
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر). برای گروهبندی «یک منشی، چند پزشک» از `secretary_uuid` (uuid کاربرِ منشی) استفاده کنید
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| ------------------- | ---- | ---------------- |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
|
||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
||
|
||
---
|
||
|
||
## PUT `/api/v1/secretaries/clinic/{clinicUuid}/doctors`
|
||
|
||
همگامسازی مجموعهی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواستهشده افزوده/فعال و بقیه غیرفعال میشوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
|
||
|
||
> ردیفهای تازهساختهشده `national_code`، `address` و `permissions` را از ردیفهای موجودِ همان منشی کپی میکنند تا پروفایل یک شخص روی همهی پزشکانش یکسان بماند. اگر همراه با ویرایش پروفایل صدا زده میشود، اول `PATCH /api/v1/secretary/{uuid}` روی ردیفهای موجود و بعد این اندپوینت را فراخوانی کنید.
|
||
|
||
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
| ------------ | ------------- | ----------- |
|
||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||
|
||
### Request Body (`application/json`)
|
||
|
||
```json
|
||
{
|
||
"secretary_uuid": "550e8400-...",
|
||
"doctor_uuids": ["uuid-doc-a", "uuid-doc-b"]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
| ---------------- | --------------- | -------- | ------------------------------------------------------------ |
|
||
| `secretary_uuid` | string (UUID) | ✅ | uuid کاربرِ منشی (همان `secretary_uuid` خروجی لیست/ساخت) |
|
||
| `doctor_uuids` | string[] (UUID) | ✅ | مجموعهی نهاییِ پزشکان؛ نبودها افزوده، اضافهها غیرفعال میشوند |
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"added": 1,
|
||
"removed": 1,
|
||
"skipped_limit": [],
|
||
"skipped_not_in_clinic": []
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
| ----------------------- | -------- | ------------------------------------------------ |
|
||
| `added` | int | تعداد ردیفهای افزوده/فعالشده |
|
||
| `removed` | int | تعداد ردیفهای غیرفعالشده |
|
||
| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیدهاند (نادیده گرفته) |
|
||
| `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند |
|
||
|
||
### Errors
|
||
|
||
| Code | HTTP | Description |
|
||
| -------------------- | ---- | ------------------------------------ |
|
||
| `ERR_AUTH_006` | 403 | Not clinic owner nor admin |
|
||
| `ERR_VALIDATION_001` | 422 | `secretary_uuid`/`doctor_uuids` missing |
|
||
| `ERR_VALIDATION_002` | 404 | Clinic or secretary not found |
|
||
|
||
---
|
||
|
||
## محدودیت پنل اشتراکی
|
||
|
||
تعداد منشیهای مجاز بر اساس پنل فعال doctor تعیین میشود:
|
||
|
||
| پنل | حداکثر منشی |
|
||
| ------------------ | ----------- |
|
||
| Free (بدون اشتراک) | ۱ |
|
||
| Basic | ۳ |
|
||
| Professional | ۱۰ |
|
||
|
||
اگر تعداد منشیهای فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمیگرداند:
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"errors": [
|
||
{
|
||
"code": "ERR_SECRETARY_001",
|
||
"message": "پلن فعلی اجازه منشی بیشتر را نمیدهد"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
برای افزایش محدودیت، باید پنل را از `POST /api/v1/subscription/trial` (تریال) یا `POST /api/v1/subscription-payment` (پرداخت) ارتقاء داد.
|
||
|
||
---
|
||
|
||
## سهم منشی از نوبتهای آنلاین (درآمد و تسویه)
|
||
|
||
ادمین میتواند برای هر رابطهٔ منشی–پزشک/کلینیک، محاسبهٔ درآمد از نوبتهای آنلاین را
|
||
فعال کند و درصد بدهد ([admin.md](admin.md#put-apiv1adminsecretaryuuidonline-share)).
|
||
سهم از **مبلغ خالص** نوبت گرفته میشود: ابتدا هزینهٔ پنل پیامک، بعد مالیات، سپس درصدِ
|
||
منشی روی «خالصِ پس از مالیات» — همان مبنایی که پورسانت نماینده از آن محاسبه میشود
|
||
([settlement.md](settlement.md)).
|
||
|
||
**«آنلاین» یعنی چه؟** تقسیم مالی تنها پس از پرداخت موفق درگاه (`PaymentManager`) اجرا
|
||
میشود؛ نوبتی که در پنل ثبت و «قطعی» میشود از این مسیر عبور نمیکند و سهمی نمیسازد.
|
||
انتساب بر پایهٔ محیط نوبت است: کلینیکِ نوبت، وگرنه خودِ پزشک. اگر چند منشیِ سهمبر وجود
|
||
داشته باشد، **هر کدام درصد خودش** را میگیرد (تقسیم نمیشود)؛ اگر مجموع درصدها از ۱۰۰
|
||
بگذرد به نسبت کلیپ میشود و هشدار لاگ میگردد تا سهم سیستم منفی نشود.
|
||
|
||
سهم هر منشی در جدول `secretary_earnings` ثبت و بهصورت اعتبار در کیف پول همان کاربر
|
||
منظور میشود؛ برداشت از طریق `POST /api/v1/settlement` انجام میگیرد.
|
||
|
||
---
|
||
|
||
### GET `/api/v1/secretary/earnings/summary`
|
||
|
||
خلاصهٔ درآمد منشیِ جاری.
|
||
|
||
**Permission:** `AUTH` (کاربر منشی)
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"enabled": true,
|
||
"share_percent": 5,
|
||
"relations": [
|
||
{ "relation_uuid": "rel-…", "doctor_name": "دکتر احمدی", "clinic_name": null, "share_percent": 5 }
|
||
],
|
||
"today_rials": 500000,
|
||
"this_month_rials": 3000000,
|
||
"total_rials": 9000000,
|
||
"appointments_count": 4,
|
||
"wallet_balance_rials": 9000000
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
| فیلد | توضیح |
|
||
|------|-------|
|
||
| `enabled` | `false` یعنی هیچ رابطهٔ فعالی با سهمِ روشن ندارد؛ پنل پیام «فعال نیست» نشان میدهد (خطا نمیدهیم) |
|
||
| `share_percent` | درصد اولین رابطهٔ سهمبر؛ تفکیک کامل در `relations` |
|
||
| `today_rials` | از نیمهشب امروز |
|
||
| `this_month_rials` | ۳۰ روز گذشته |
|
||
| `wallet_balance_rials` | موجودی کیف پول همان کاربر (مبنای تسویه) |
|
||
|
||
---
|
||
|
||
### GET `/api/v1/secretary/earnings/report`
|
||
|
||
گزارش سطر-به-سطر سهم منشی (paginated).
|
||
|
||
**Permission:** `AUTH` (کاربر منشی)
|
||
|
||
#### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | پیشفرض ۱ |
|
||
| `limit` | integer | ❌ | پیشفرض ۱۵، حداکثر ۱۰۰ |
|
||
| `from` | integer | ❌ | Unix — از تاریخ |
|
||
| `to` | integer | ❌ | Unix — تا تاریخ |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "earning-uuid-…",
|
||
"appointment_uuid": "appt-uuid-…",
|
||
"doctor_name": "دکتر احمدی",
|
||
"gross_rials": 10000000,
|
||
"sms_fee_rials": 1000000,
|
||
"tax_rials": 818182,
|
||
"net_after_tax_rials": 8181818,
|
||
"share_percent": 5,
|
||
"share_rials": 409091,
|
||
"created_at": 1700000000
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
|
||
منشیِ بدون سهم، پاسخ `200` با آرایهٔ خالی میگیرد (نه `403`).
|
||
|
||
---
|
||
|
||
### GET `/api/v1/secretary/me`
|
||
|
||
پروفایل منشیِ جاری: رابطهها با تنظیمات سهم + شمارههای شبا.
|
||
|
||
**Permission:** `AUTH` (کاربر منشی)
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"full_name": "زهرا رضایی",
|
||
"mobile": "0912…",
|
||
"bank_account": [
|
||
{ "id": "iban-uuid-…", "iban": "IR…", "bank_name": "ملی", "owner_name": null, "verified": false, "created_at": 1700000000 }
|
||
],
|
||
"relations": [
|
||
{ "relation_uuid": "rel-…", "doctor_name": "دکتر احمدی", "clinic_name": null, "online_share_enabled": true, "online_share_percent": 5 }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### POST `/api/v1/secretary/iban`
|
||
|
||
افزودن شماره شبا (حداکثر ۲) به پروفایل کاربرِ منشی — مثل پنل نماینده.
|
||
|
||
**Permission:** `AUTH` (کاربر منشی)
|
||
|
||
#### Request Body (`application/json`)
|
||
```json
|
||
{ "iban": "IR123456789012345678901234", "bank_name": "ملی", "owner_name": "زهرا رضایی" }
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `iban` | string | ✅ | الگوی `IR` + ۲۴ رقم (فاصلهها حذف میشود) |
|
||
| `bank_name` | string | ❌ | نام بانک |
|
||
| `owner_name` | string | ❌ | نام صاحب حساب |
|
||
|
||
#### Response `201`
|
||
```json
|
||
{ "success": true, "data": { "data": { "bank_account": [ { "id": "…", "iban": "IR…", "verified": false } ] } } }
|
||
```
|
||
|
||
`verified` همیشه `false` ثبت میشود؛ **تأیید فقط از سمت ادمین** انجام میگیرد و تسویه تنها با شبای تأییدشده مجاز است.
|
||
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_VALIDATION_001` | 422 | شبا نامعتبر (`field: iban`) |
|
||
| `ERR_VALIDATION_001` | 422 | بیش از دو شبا (`field: iban`) |
|
||
|
||
---
|
||
|
||
### DELETE `/api/v1/secretary/iban/{id}`
|
||
|
||
حذف یکی از شباهای منشیِ جاری.
|
||
|
||
**Permission:** `AUTH` (کاربر منشی)
|
||
|
||
#### Response `200`
|
||
`{ success, data: { data: { bank_account: [...] } } }`
|
||
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | پروفایل/شبا یافت نشد |
|
||
|
||
> کیف پول و تسویه اندپوینت اختصاصی ندارند: `GET /api/v1/wallet/balance`، `GET /api/v1/wallet/transactions` و `POST /api/v1/settlement` کاربر-محورند ([settlement.md](settlement.md)).
|