feat: Add online share functionality for secretaries

- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments.
- Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements.
- Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`.
- Implemented `SecretaryEarning` entity and repository for managing secretary earnings.
- Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments.
- Added `UserIbanResolver` service to handle user IBAN retrieval and management.
- Created `HasIbansTrait` for entities to manage IBANs in a JSON format.
- Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
This commit is contained in:
hamed
2026-07-25 18:34:18 +03:30
parent 73a608c3dd
commit 8d2b0d908a
33 changed files with 2564 additions and 76 deletions
+81 -1
View File
@@ -940,7 +940,87 @@ List all secretaries.
| `search` | string | ❌ | Search by mobile |
### Response `200`
Paginated secretary list with linked doctor info.
Paginated secretary list with linked doctor info. هر ردیف علاوه بر مجوزها،
`online_share_enabled` و `online_share_percent` (سهم منشی از نوبت‌های آنلاین) را هم دارد.
---
### GET `/api/v1/admin/secretary/{uuid}`
جزئیات یک **رابطهٔ** منشی–پزشک/کلینیک (`uuid` = `DoctorSecretary.uuid`، همان uuid لیست بالا) به‌همراه تنظیمات سهم و خلاصهٔ درآمد.
**Permission:** `ROLE_ADMIN`
#### Response `200`
```json
{
"success": true,
"data": {
"data": {
"uuid": "rel-uuid-…",
"secretary_uuid": "user-uuid-…",
"user_name": "زهرا رضایی",
"mobile_number": "0912…",
"doctor_name": "دکتر احمدی",
"doctor_uuid": "doc-uuid-…",
"owner_type": "doctor",
"clinic_uuid": null,
"clinic_name": null,
"is_active": true,
"online_share_enabled": true,
"online_share_percent": 5,
"permissions": { "…": {} },
"created_at": 1700000000,
"earnings": {
"total_rials": 4500000,
"this_month_rials": 1500000,
"appointments_count": 9
}
}
}
}
```
`earnings` روی **کاربرِ منشی** جمع می‌شود (نه فقط این رابطه): مجموع همهٔ سهم‌های ثبت‌شده در `secretary_earnings`. `this_month_rials` = ۳۰ روز گذشته.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد |
---
### PUT `/api/v1/admin/secretary/{uuid}/online-share`
فعال/غیرفعال‌کردن محاسبهٔ درآمد منشی از نوبت‌های آنلاین و تعیین درصد سهم. تنظیم
**per-relation** است: یک منشی می‌تواند برای یک پزشک سهم داشته باشد و برای دیگری نه.
**Permission:** `ROLE_ADMIN`
#### Request Body (`application/json`)
```json
{ "enabled": true, "percent": 5 }
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `enabled` | boolean | ✅ | محاسبهٔ سهم برای این رابطه فعال باشد؟ |
| `percent` | number | ✅ | درصد سهم از **مبلغ خالص** نوبت (۰ تا ۱۰۰) |
#### Response `200`
همان شکل رابطه (`DoctorSecretary::toArray()`) پس از ذخیره.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد |
| `ERR_VALIDATION_001` | 422 | `percent` خارج از ۰–۱۰۰ (`field: percent`) |
| `ERR_VALIDATION_001` | 422 | `enabled=true` با `percent=0` (`field: percent`) |
> **مبنای محاسبه:** سهم منشی مثل پورسانت نماینده از «خالصِ پس از مالیات» گرفته می‌شود — ابتدا هزینهٔ پنل پیامک، بعد مالیات، بعد سهم‌ها. تنها نوبت‌هایی که **آنلاین** پرداخت می‌شوند سهم می‌سازند (نوبت ثبت‌شده در پنل از مسیر تقسیم مالی عبور نمی‌کند). جزئیات: [settlement.md](settlement.md) و [secretary.md](secretary.md).
---
+173
View File
@@ -534,3 +534,176 @@ Get all secretaries across **all doctors** of a clinic.
```
برای افزایش محدودیت، باید پنل را از `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)).
+22 -2
View File
@@ -107,7 +107,12 @@ Request a settlement (withdrawal from wallet to bank account).
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount_rials` | integer | ✅ | Amount to withdraw (must be ≤ wallet balance) |
| `iban_id` | string | ✅ | شناسه‌ی یکی از شباهای **تأییدشده‌ی** نماینده (از `GET /api/v1/representation/me``bank_account[].id`) |
| `iban_id` | string | ✅ | شناسه‌ی یکی از شباهای **تأییدشده‌ی همان کاربر** |
**شبا از کجا خوانده می‌شود؟** تسویه دیگر مخصوص نماینده نیست: `UserIbanResolver` ابتدا شبای
نماینده (`GET /api/v1/representation/me``bank_account[].id`) و در نبودش شبای پروفایل
کاربر (`GET /api/v1/secretary/me``bank_account[].id`) را بررسی می‌کند. بنابراین هر نقشی
که موجودی کیف پول دارد — از جمله **منشی** با سهم نوبت‌های آنلاین — می‌تواند برداشت کند.
> شبای انتخابی به‌صورت snapshot (`iban`, `bank_name`, `owner_name`) داخل خود رکورد تسویه ذخیره می‌شود؛ حذف بعدی شبا در پروفایل، این رکورد را تغییر نمی‌دهد. مبلغ همان لحظه‌ی ثبت از کیف‌پول کسر (debit) می‌شود.
@@ -263,7 +268,22 @@ Updated settlement object with `status: "rejected"`.
## FinancialBreakdown (لاگ مالی)
علاوه بر تسویه‌حساب دستی، کیف‌پول نماینده به‌صورت خودکار از طریق `CommissionService` هنگام پرداخت موفقِ نوبت/اشتراک شارژ می‌شود (`WalletTransaction` credit). هر واریز یک ردیف `FinancialBreakdown` ثبت می‌کند که تفکیک کامل تراکنش (ناخالص، هزینه پیامک، مالیات، خالص، درصد و سهم پورسانت، سهم سیستم) را نگه می‌دارد. ثبت idempotent است (بر اساس `payment_id`). گزارش‌ها از طریق `GET /api/v1/admin/financial-breakdowns` و `GET /api/v1/admin/financial-summary` در دسترس‌اند — جزئیات در `docs/api/admin.md`.
علاوه بر تسویه‌حساب دستی، کیف‌پول نماینده به‌صورت خودکار از طریق `CommissionService` هنگام پرداخت موفقِ نوبت/اشتراک شارژ می‌شود (`WalletTransaction` credit). هر واریز یک ردیف `FinancialBreakdown` ثبت می‌کند که تفکیک کامل تراکنش (ناخالص، هزینه پیامک، مالیات، خالص، درصد و سهم پورسانت، **سهم منشی**، سهم سیستم) را نگه می‌دارد. ثبت idempotent است (بر اساس `payment_id`). گزارش‌ها از طریق `GET /api/v1/admin/financial-breakdowns` و `GET /api/v1/admin/financial-summary` در دسترس‌اند — جزئیات در `docs/api/admin.md`.
### ترتیب تقسیم و سهم منشی
```
۱) هزینهٔ پنل پیامک ← از ناخالص کم می‌شود
۲) مالیات ← استخراجی از باقی‌مانده: tax = amount × p/(100+p)
۳) سهم‌ها، همه از «خالصِ پس از مالیات»:
پورسانت نماینده = netAfterTax × commission_percent / 100
سهم هر منشی = netAfterTax × online_share_percent / 100
سهم سیستم = ناخالص − پیامک − مالیات − پورسانت − مجموع سهم منشی‌ها
```
- سهم منشی **مستقل از نماینده** است: نوبتِ بدون نمایندهٔ منطبق هم اگر منشیِ سهم‌بر داشته باشد، تفکیک مالی می‌سازد.
- `financial_breakdowns.secretary_share_rials` مجموع سهم منشی‌های همان پرداخت است؛ تفکیک هر منشی در جدول `secretary_earnings` (با `share_percent` و `relation_uuid`) ذخیره می‌شود و گزارش پنل منشی از همان خوانده می‌شود ([secretary.md](secretary.md)).
- اگر مجموع درصدها (پورسانت + سهم منشی‌ها) از ۱۰۰ بگذرد، به نسبت کلیپ و یک هشدار با `payment_uuid` لاگ می‌شود تا سهم سیستم منفی نشود.
---