clinic.md's default envelope is regenerated from the running app, so it now shows all 17 resources instead of 13, including services with its full create/delete actions. Both role docs point at permission.md for the shared registry and spell out the merge rule that makes new resources work on existing rows: deleting a key means "take the default", not "deny" — denying requires an explicit false. A systematic sweep over every gated route with all permissions off found two places where the docs claimed enforcement that does not exist: - GET /api/v1/subscription/my returns 200 with every permission off. Only trial is gated. - ServiceCatalogController has no gate at all. Both are pre-existing and both are left as-is rather than half-fixed: their endpoints are also consumed by the booking and subscription flows, where a hard gate would break secretaries who legitimately need them. The docs now say so. 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). ⚠ `ServiceCatalogController` (دستهبندی سرویسها، گروهها، روابط) گِیت **ندارد** — اندپوینتهایش در جریانِ ثبت نوبت هم مصرف میشوند و بستنِ یکجا نوبتدهی منشی را میشکند. 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::trial` (create)، `PaymentController::initiateSubscription` (create). ⚠ خواندنِ `GET /api/v1/subscription/my` گِیت **ندارد** — با همهٔ مجوزها خاموش هم ۲۰۰ میدهد؛ چون FeatureGate و useSubscription همهجا صداش میزنند، بستنش نیاز به بررسی جداگانه دارد | 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)).
|