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
@@ -0,0 +1,301 @@
# سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)
## زمینه
نوبتی که از سایت عمومی به‌صورت **آنلاین** رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: `CommissionService::settle()` ابتدا هزینهٔ پنل پیامک را کم می‌کند، بعد مالیات را از باقی‌مانده استخراج می‌کند و در آخر پورسانت **نماینده** را از «خالصِ پس از مالیات» می‌گیرد و بقیه سهم سیستم می‌شود. کل این تفکیک در `financial_breakdowns` ثبت و سهم نماینده به‌صورت `WalletTransaction` اعتبار می‌شود؛ نماینده بعداً با شبای تأییدشده‌اش درخواست تسویه می‌زند.
منشی امروز هیچ سهمی از این جریان ندارد: نه فیلدی برای فعال‌سازی/درصد دارد، نه صفحهٔ جزئیاتی در پنل ادمین (`/admin/secretaries` فقط لیست است و `/admin/secretaries/{uuid}` وجود ندارد)، نه راهی برای ثبت شبا و دیدن درآمد.
## هدف
۱) ادمین در `/admin/secretaries/{uuid}` (که `uuid` همان `DoctorSecretary.uuid` است) بتواند برای هر رابطهٔ منشی–پزشک/کلینیک:
- محاسبهٔ درآمد منشی از نوبت‌های آنلاین را **فعال/غیرفعال** کند
- **درصد سهم** منشی را تعیین کند
۲) سهم منشی از **مبلغ خالص** نوبت حساب شود — یعنی پس از کسر هزینهٔ پیامک، مالیات و کارمزد/کسورات؛ دقیقاً همان `netAfterTax` که پورسانت نماینده هم از آن گرفته می‌شود.
۳) اگر این قابلیت فعال باشد، منشی در پنل خودش:
- درآمد **روزانه / ماهانه** و گزارش سطر-به-سطر نوبت‌های آنلاین را ببیند
- **شماره شبا** ثبت و مدیریت کند (مثل پنل نماینده) تا برای تسویه استفاده شود
## فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `src/Settlement/Service/CommissionService.php` | موتور تقسیم مالی؛ محل افزودن سهم منشی |
| `src/Settlement/Entity/FinancialBreakdown.php` | تفکیک مالی هر پرداخت (`grossRials`, `smsFeeRials`, `taxRials`, `netAfterTaxRials`, `commissionPercent`, `representationShareRials`, `systemShareRials`) |
| `src/Settlement/Repository/FinancialBreakdownRepository.php` | `existsForPayment()` — گاردِ پردازش دوباره |
| `src/Payment/Service/PaymentManager.php` | خط ۳۱۸: تنها فراخوانِ `processAppointment` پس از پرداخت موفق |
| `src/Secretary/Entity/DoctorSecretary.php` | رابطهٔ منشی–پزشک/کلینیک (`permissions`, `active`, `ownerType`, `clinic`) |
| `src/Secretary/Controller/SecretaryController.php` | اندپوینت‌های منشی |
| `src/Admin/Controller/AdminApiController.php` | `GET /api/v1/admin/secretaries` (خط ~۱۴۹۵) — لیست ادمین با `ds.uuid` |
| `src/Settlement/Controller/SettlementController.php` | `POST /api/v1/settlement` (خط ۱۴۴) — **شبا را فقط از `Representation` می‌خواند** |
| `src/Representation/Entity/Representation.php` | الگوی شبا: `bank_account` JSON + `addIban()/removeIban()/findVerifiedIban()` (حداکثر ۲) |
| `src/Representation/Controller/RepresentationActionController.php` | `POST/DELETE /api/v1/representation/iban` و `GET /api/v1/representation/finance/report` (خط ۷۸۸) — الگوی گزارش مالی |
| `src/UserProfile/Entity/UserProfile.php` | پروفایل کاربر (بدون فیلد بانکی) |
| `src/Config/Repository/SiteConfigRepository.php` | `sms_panel_fee_rials`, `tax_enabled`, `tax_percent`, `appointment_commission_enabled` |
| `assets/admin/pages/SecretariesPage.tsx` | لیست منشی‌های ادمین |
| `assets/admin/pages/RepresentationDetailPage.tsx` | الگوی صفحهٔ جزئیات ادمین |
| `assets/admin/pages/RepresentationFinancePage.tsx` · `RepresentationSettlementPage.tsx` | الگوی گزارش درآمد + تسویه/شبا در پنل |
| `assets/admin/App.tsx` | خط ۲۱۱ (`secretaries`) — محل افزودن route جزئیات و صفحات پنل منشی |
| `docs/api/secretary.md` · `docs/api/admin.md` · `docs/api/settlement.md` | مستندات |
## وضعیت فعلی
### ۱) سهم فقط برای نماینده محاسبه می‌شود — `src/Settlement/Service/CommissionService.php:85-143`
```php
$gross = $payment->getAmountRials();
// مرحله ۱: کسر هزینه ثابت پنل پیامک.
$smsFee = (int) $this->configRepo->get('sms_panel_fee_rials');
$afterSms = max(0, $gross - $smsFee);
// مرحله ۲: مالیاتِ استخراجی از مبلغِ شامل مالیات: tax = amount × p/(100+p).
$taxEnabled = $this->configRepo->get('tax_enabled') === '1';
$taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0;
$taxRials = ($taxEnabled && $taxPercent > 0)
? (int) round($afterSms * $taxPercent / (100 + $taxPercent))
: 0;
$netAfterTax = $afterSms - $taxRials;
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات.
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare;
```
و گاردِ ورودی — بدون نماینده، کل متد زودتر برمی‌گردد و **هیچ** تفکیکی ثبت نمی‌شود:
```php
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
// هر دو شرط لازم است و باید یکی باشند.
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
$rep = $this->resolveRep($doctorRepId);
if ($rep === null) return;
...
```
### ۲) درخواست تسویه شبا را فقط از نماینده می‌خواند — `src/Settlement/Controller/SettlementController.php:158-162`
```php
$rep = $this->representationRepo->findByUser($user);
$iban = $rep?->findVerifiedIban($ibanId);
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id');
}
```
یعنی منشی حتی با موجودی کیف پول، امکان ثبت درخواست تسویه ندارد.
### ۳) `DoctorSecretary` هیچ فیلد مالی ندارد — `src/Secretary/Entity/DoctorSecretary.php:57-80`
```php
#[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])]
private string $ownerType;
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
private ?array $permissions = null;
// …
#[ORM\Column(type: 'boolean')]
private bool $active = true;
```
## وظایف
> ترتیب: ۱ → ۸ (بک‌اند اول، بعد فرانت، بعد مستندات/تست). هر گام مستقل تست‌شدنی باشد.
### ۱. فیلدهای سهم روی رابطهٔ منشی
`src/Secretary/Entity/DoctorSecretary.php`:
```php
/** محاسبهٔ سهم منشی از نوبت‌های آنلاینِ همین پزشک/کلینیک فعال است؟ */
#[ORM\Column(name: 'online_share_enabled', type: 'boolean', options: ['default' => false])]
private bool $onlineShareEnabled = false;
/** درصد سهم منشی از «خالصِ پس از مالیات» نوبت آنلاین. */
#[ORM\Column(name: 'online_share_percent', type: 'decimal', precision: 5, scale: 2)]
private string $onlineSharePercent = '0.00';
```
- getter/setter + کلیدهای `online_share_enabled` و `online_share_percent` در `toArray()`
- migration (پیش‌فرض‌ها طوری که رفتار موجود عوض نشود: غیرفعال و صفر)
- **گرین درست:** این تنظیم per-relation است (هر ردیف `DoctorSecretary` = یک منشی برای یک پزشک/کلینیک)، چون `uuid` در `/admin/secretaries/{uuid}` همان `DoctorSecretary.uuid` است.
### ۲. شبای منشی (سطح کاربر) + resolver مشترک تسویه
شبا به **کاربر** تعلق دارد نه به رابطه؛ پس روی `src/UserProfile/Entity/UserProfile.php`:
```php
/** ۰ تا ۲ شماره شبا؛ هر آیتم: { id, iban, bank_name, owner_name, verified, created_at } */
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null;
```
با همان سه متد الگوی نماینده: `getIbans()`, `addIban()` (سقف ۲، خطای `iban_limit``removeIban($id)`, `findVerifiedIban($id)` — کد را از `Representation` **کپی نکن**؛ یک trait مشترک `src/Shared/Entity/HasIbansTrait.php` بساز و هر دو entity از آن استفاده کنند (اجتناب از دو پیاده‌سازی واگرا).
سپس `src/Settlement/Service/UserIbanResolver.php`:
```php
/** شبای تأییدشدهٔ یک کاربر، از هر منبعی که دارد: نماینده، وگرنه پروفایل کاربر. */
public function findVerifiedIban(User $user, string $ibanId): ?array
```
و `SettlementController::request()` به‌جای `representationRepo->findByUser(...)` از همین resolver استفاده کند. بدونِ این تغییر، منشی نمی‌تواند تسویه بزند.
### ۳. سهم منشی در موتور تقسیم مالی
`src/Settlement/Entity/FinancialBreakdown.php`: دو ستون تازه + migration
```php
#[ORM\Column(name: 'secretary_share_percent', type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
private string $secretarySharePercent = '0.00';
#[ORM\Column(name: 'secretary_share_rials', type: 'integer', options: ['default' => 0])]
private int $secretaryShareRials = 0;
/** کاربرِ منشیِ اعتبارشده (برای گزارش پنل منشی). */
#[ORM\Column(name: 'secretary_user_id', type: 'integer', nullable: true)]
private ?int $secretaryUserId = null;
```
`src/Settlement/Service/CommissionService.php`:
- متد جدید عمومی برای نوبت آنلاین که **مستقل از نماینده** کار کند. الآن اگر نوبت نمایندهٔ منطبق نداشته باشد، `processAppointment` زودتر return می‌کند؛ سهم منشی نباید به وجود نماینده گره بخورد. بازآرایی پیشنهادی:
```php
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->breakdownRepo->existsForPayment($payment)) return;
$rep = $this->eligibleRep($doctorRepId, $bookingRepId); // منطق و گاردهای فعلی، دست‌نخورده
$secretaries = $this->secretaryShares->for($payment); // آرایهٔ [{user, percent}]
if ($rep === null && $secretaries === []) return; // چیزی برای تقسیم نیست
$this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, $rep, $secretaries, $doctorId, null);
}
```
- در `settle()` پس از محاسبهٔ `netAfterTax` (بدون تغییر در دو مرحلهٔ اول):
```php
// سهم منشی — مثل نماینده از «خالصِ پس از مالیات»، نه از مبلغ کل.
$secretaryShare = (int) round($netAfterTax * $secretaryPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryShare;
```
- برای هر منشیِ واجد شرط یک `WalletTransaction` از نوع `TYPE_CREDIT` با توضیح فارسی (`سهم نوبت آنلاین <orderId>`) ثبت شود — همان الگوی نماینده.
- `systemShare` هرگز نباید منفی شود: مجموع `repPercent + Σ secretaryPercent` را در `settle()` به ۱۰۰ کلیپ کن و اگر کلیپ شد یک `logger->warning` با `payment_uuid` بنویس (سکوت نکن).
سرویس جدید `src/Secretary/Service/SecretaryShareResolver.php`:
```php
/**
* منشی‌هایی که از این نوبت آنلاین سهم می‌برند: رابطهٔ فعالِ همان پزشک (یا کلینیکِ نوبت)
* با online_share_enabled و درصد > ۰.
*
* @return list<array{user: User, percent: float, relation_uuid: string}>
*/
public function for(Payment $payment): array
```
- مبنای انتساب: `payment->getAppointment()` → اگر `appointment->getClinic()` پر بود، رابطه‌های `ownerType='clinic'` همان کلینیک؛ وگرنه رابطه‌های همان پزشک.
- فقط `active = true` و `online_share_enabled = true`.
- «آنلاین» یعنی همین مسیر: `CommissionService::processAppointment` تنها از `PaymentManager` (پرداخت موفق درگاه) صدا زده می‌شود؛ نوبت‌های ثبت‌شده در پنل از این مسیر عبور نمی‌کنند و سهمی نمی‌سازند. این را در docblock بنویس.
### ۴. اندپوینت‌های ادمین برای مدیریت سهم
در `src/Admin/Controller/AdminApiController.php` (کنار `GET /api/v1/admin/secretaries`، همان `#[IsGranted('ROLE_ADMIN')]`):
```php
#[Route('/api/v1/admin/secretary/{uuid}', methods: ['GET'])]
public function secretaryDetail(string $uuid): JsonResponse
{
// { uuid, secretary_name, secretary_mobile, owner_type, doctor: {uuid,name}, clinic: {uuid,name}|null,
// permissions, active, online_share_enabled, online_share_percent,
// earnings: { total_rials, this_month_rials, appointments_count } }
}
#[Route('/api/v1/admin/secretary/{uuid}/online-share', methods: ['PUT'])]
public function saveSecretaryOnlineShare(string $uuid, Request $request): JsonResponse
{
// body: { enabled: true, percent: 5 }
// اعتبارسنجی: 0 ≤ percent ≤ 100 → وگرنه error(ERR_VALIDATION_001, …, 422, 'percent')
// enabled=true با percent=0 → 422 (فعال‌سازی بی‌درصد بی‌معناست)
}
```
- در پاسخ `GET /api/v1/admin/secretaries` هم دو کلید `online_share_enabled` / `online_share_percent` اضافه شود (همان DQL موجود، بدون کوئری اضافه).
### ۵. اندپوینت‌های پنل منشی
در `src/Secretary/Controller/SecretaryController.php` (گِیت: کاربر باید رابطهٔ فعال منشی داشته باشد):
```php
#[Route('/api/v1/secretary/earnings/summary', methods: ['GET'])]
public function earningsSummary(#[CurrentUser] User $user): JsonResponse
{
// { enabled: bool, share_percent: float, today_rials, this_month_rials,
// total_rials, wallet_balance_rials, appointments_count }
}
#[Route('/api/v1/secretary/earnings/report', methods: ['GET'])]
public function earningsReport(Request $request, #[CurrentUser] User $user): JsonResponse
{
// paginated؛ query: page, limit, from, to (Unix)
// هر ردیف: { uuid, appointment_uuid, doctor_name, created_at,
// gross_rials, sms_fee_rials, tax_rials, net_after_tax_rials,
// share_percent, share_rials }
}
```
- الگوی کوئری را از `RepresentationActionController::financeReport()` (خط ۷۸۸) بگیر: DQL روی `FinancialBreakdown` با `getArrayResult()`، فیلتر `b.secretaryUserId = :userId`، `paginated()` برای پاسخ.
- `enabled: false` وقتی هیچ رابطهٔ فعالِ دارای سهم ندارد؛ در این حالت گزارش خالی برگردد (نه ۴۰۳) تا فرانت بتواند پیام «این قابلیت برای شما فعال نیست» نشان دهد.
- شبا: اندپوینت‌های `POST /api/v1/secretary/iban` و `DELETE /api/v1/secretary/iban/{id}` با همان قواعد نماینده (سقف ۲، `verified` فقط توسط ادمین) روی `UserProfile` ذخیره شوند؛ `GET /api/v1/secretary/me` هم `bank_account` را برگرداند.
- کیف پول و تسویه اندپوینت تازه لازم ندارند: `/api/v1/wallet/balance`, `/api/v1/wallet/transactions`, `POST /api/v1/settlement` کاربر-محورند و با اصلاح گام ۲ برای منشی کار می‌کنند.
### ۶. پنل ادمین — صفحهٔ جزئیات منشی
- route جدید در `assets/admin/App.tsx` کنار خط ۲۱۱: `secretaries/:uuid``SecretaryDetailPage` با `RoleRoute roles={['admin']}`.
- `assets/admin/pages/SecretaryDetailPage.tsx` (جدید) با الگوی `RepresentationDetailPage.tsx``PageHeader` + کارت‌های موجود، بدون طراحی تازه:
- کارت «اطلاعات منشی» (نام، موبایل، پزشک/کلینیک، وضعیت، مجوزها)
- کارت «سهم درآمد نوبت‌های آنلاین»: سوییچ فعال/غیرفعال + ورودی درصد (`digitsOnly(value, 3)`) + دکمهٔ ذخیره → `PUT /api/v1/admin/secretary/{uuid}/online-share`؛ پس از موفقیت `queryKey` هم لیست و هم جزئیات invalidate شود
- کارت خلاصهٔ درآمد (`earnings` از پاسخ جزئیات) با `formatRial`
- در `assets/admin/pages/SecretariesPage.tsx`: ستون «سهم آنلاین» (`—` وقتی غیرفعال) + کلیک ردیف/اکشن به صفحهٔ جزئیات.
### ۷. پنل منشی — درآمد و شبا
- `assets/admin/pages/SecretaryEarningsPage.tsx` (جدید): کارت‌های «امروز / این ماه / کل» + `DataTable` گزارش با `Pagination` و فیلتر تاریخ (`PersianDateInput`)؛ ستون‌ها: تاریخ (`formatDate` شمسی)، پزشک، مبلغ نوبت، کسورات، خالص، درصد، سهم منشی.
- `assets/admin/pages/SecretarySettlementPage.tsx` (جدید): مثل `RepresentationSettlementPage.tsx` — موجودی کیف پول، مدیریت شبا (افزودن/حذف، حداکثر ۲، نمایش `verified`)، فرم درخواست تسویه با انتخاب شبای تأییدشده، و لیست درخواست‌ها.
- routeها با `RoleRoute roles={['secretary']}`؛ آیتم منو فقط وقتی `enabled` در `earnings/summary` درست است (منوی نقش منشی همان جایی که بقیهٔ آیتم‌های منشی تعریف شده‌اند).
- هیچ محاسبهٔ مالی در فرانت تکرار نشود: همهٔ اعداد از سرور می‌آیند.
### ۸. مستندات و تست
- `docs/api/admin.md`: `GET /api/v1/admin/secretary/{uuid}`، `PUT …/online-share` (بدنه، خطاها)، و فیلدهای تازه در لیست منشی‌ها.
- `docs/api/secretary.md`: `earnings/summary`، `earnings/report` (با پارامترهای query و مثال JSON)، اندپوینت‌های شبا، و اشاره به این‌که کیف پول/تسویه از `docs/api/settlement.md` می‌آید.
- `docs/api/settlement.md`: تسویه دیگر مخصوص نماینده نیست؛ شبا از نماینده **یا** پروفایل کاربر resolve می‌شود.
- PHPUnit:
- `CommissionService`: منشیِ فعال با ۵٪ → `secretary_share_rials = round(netAfterTax × 5 / 100)` و `system_share = gross sms tax rep secretary`؛ نوبت **بدون** نماینده ولی با منشیِ فعال → تفکیک ساخته شود (رگرسیونِ گاردِ فعلی)؛ منشیِ غیرفعال/درصد صفر → هیچ سهمی؛ پرداخت تکراری → دوباره پردازش نشود؛ مجموع درصدها > ۱۰۰ → کلیپ و لاگ، `systemShare >= 0`.
- `SecretaryShareResolver`: نوبت کلینیکی → منشی‌های همان کلینیک؛ نوبت مطب → منشی‌های همان پزشک؛ رابطهٔ غیرفعال حساب نشود.
- Controller ادمین: `percent = 101` → ۴۲۲ · `enabled=true, percent=0` → ۴۲۲ · کاربر غیرادمین → ۴۰۳.
- `SettlementController`: منشی با شبای تأییدشده در `UserProfile` می‌تواند تسویه بزند؛ شبای تأییدنشده → ۴۲۲.
- `SecretaryController`: منشیِ بدون سهم → `enabled: false` و گزارش خالی با ۲۰۰.
- Vitest: `SecretaryDetailPage` (ذخیرهٔ سوییچ+درصد، خطای درصد > ۱۰۰)، `SecretaryEarningsPage` (نمایش کارت‌ها و ردیف‌ها از پاسخ paginated).
- اجرای واقعی: `ddev exec php bin/phpunit` · `ddev exec php vendor/bin/phpstan analyse` · `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn dev` · `npx vitest run` (⚠️ vitest داخل ddev اجرا نمی‌شود — روی host اجرا کن).
## نکات مهم
- **ترتیب کسورات تغییر نکند:** پیامک → مالیات → سهم‌ها. سهم منشی و پورسانت نماینده هر دو از **`netAfterTax`** گرفته می‌شوند؛ نه از `gross` و نه از باقی‌ماندهٔ پس از سهم دیگری (وگرنه ترتیبِ اجرا روی مبلغ اثر می‌گذارد).
- **گرد کردن:** `(int) round(...)` مثل کد فعلی؛ `systemShare` همیشه از تفریق حساب شود تا مجموع سهم‌ها با `gross` برابر بماند.
- **idempotency:** `existsForPayment()` باید **قبل از** هر اعتبارِ کیف پول چک شود؛ الآن داخل `settle()` است و با اضافه‌شدن مسیر منشی باید در `processAppointment` هم گارد شود.
- **فقط نوبت آنلاین:** نوبت‌های ثبت‌شده در پنل (`POST /api/v1/my/appointment` و مودال «قطعی کردن نوبت») از `PaymentManager` عبور نمی‌کنند و سهم نمی‌سازند. اگر بعداً لازم شد، مسیر جداگانه‌ای باشد نه تغییر این یکی.
- **چند منشی:** یک پزشک/کلینیک می‌تواند چند منشیِ دارای سهم داشته باشد؛ هر کدام درصد خودش را می‌گیرد (مستقل، نه تقسیم‌شده) و همه در یک `FinancialBreakdown` ثبت می‌شوند — پس `secretary_share_rials` مجموع است و `secretary_user_id` برای گزارش تک‌نفره کافی نیست. اگر بیش از یک منشی سهم‌بر است، به ازای هر منشی یک ردیف کمکی `secretary_shares` (JSON روی همان breakdown) ذخیره کن و گزارش پنل منشی را از همان بخوان؛ تصمیم را در docblock مستند کن.
- **`verified` شبا:** افزودن شبا توسط خود منشی، ولی `verified` فقط از سمت ادمین ست می‌شود (همان قاعدهٔ نماینده)؛ تسویه فقط با شبای تأییدشده.
- **الگوهای پروژه:** پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()` · لیست‌ها با `getArrayResult()` · تاریخ‌ها Unix timestamp و نمایش با `formatDate()` شمسی · فرانت: TanStack Query v5، `SearchableSelect` به‌جای `<select>`، کامپوننت‌ها و توکن‌های موجود بدون طراحی تازه · رشته‌های UI فارسی.
- بعد از اتمام: `graphify update .` (پس از commit).
+6
View File
@@ -33,6 +33,9 @@ import BlogsPage from './pages/BlogsPage';
import BlogFormPage from './pages/BlogFormPage';
import BlogReviewPage from './pages/BlogReviewPage';
import SecretariesPage from './pages/SecretariesPage';
import SecretaryDetailPage from './pages/SecretaryDetailPage';
import SecretaryEarningsPage from './pages/SecretaryEarningsPage';
import SecretarySettlementPage from './pages/SecretarySettlementPage';
import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
import SettingsPage from './pages/SettingsPage';
import FinancialReportPage from './pages/FinancialReportPage';
@@ -209,6 +212,9 @@ export default function App() {
<Route path="blogs/:uuid/edit" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
<Route path="blog-review" element={<RoleRoute roles={['admin']}><BlogReviewPage /></RoleRoute>} />
<Route path="secretaries" element={<RoleRoute roles={['admin']}><SecretariesPage /></RoleRoute>} />
<Route path="secretaries/:uuid" element={<RoleRoute roles={['admin']}><SecretaryDetailPage /></RoleRoute>} />
<Route path="secretary-earnings" element={<RoleRoute roles={['secretary']}><SecretaryEarningsPage /></RoleRoute>} />
<Route path="secretary-settlement" element={<RoleRoute roles={['secretary']}><SecretarySettlementPage /></RoleRoute>} />
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
+27 -1
View File
@@ -11,6 +11,7 @@ import {
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon,
CurrencyDollarIcon,
DevicePhoneMobileIcon,
DocumentTextIcon,
FolderOpenIcon,
@@ -30,6 +31,7 @@ import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { usePermissions } from "../../hooks/usePermissions";
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore";
@@ -65,6 +67,8 @@ function buildSections(
dbUuid: string | null,
scope: string | null,
can: (resource: string, action: string) => boolean,
/** سهم درآمد نوبت آنلاین برای این منشی فعال است؟ آیتم‌های مالی به آن گِیت می‌شوند. */
secretaryShareEnabled = false,
): Section[] {
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
// برایش تعیین کرده ساخته می‌شود، نه به‌صورت hardcode.
@@ -446,6 +450,21 @@ function buildSections(
label: "انبارداری",
});
}
// درآمد و تسویه فقط وقتی ادمین سهم نوبت آنلاین را برای این منشی فعال کرده باشد.
if (secretaryShareEnabled) {
items.push(
{
to: "/admin/secretary-earnings",
icon: CurrencyDollarIcon,
label: "درآمد من",
},
{
to: "/admin/secretary-settlement",
icon: BanknotesIcon,
label: "تسویه حساب",
},
);
}
// منابعِ زیرمجموعهٔ «تنظیمات» (staff/discounts/sms/tags/appointment_settings/
// clinic_doctors) در سایدبار اصلی نمی‌آیند — دقیقاً مثل پزشک/کلینیک، فقط داخل
@@ -705,7 +724,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const navigate = useNavigate();
const { can } = usePermissions();
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary");
const sections = buildSections(
primaryRole,
dbUuid,
context?.scope ?? null,
can,
secretaryEarnings?.enabled ?? false,
);
const initials = (userName ?? "U").charAt(0).toUpperCase();
return (
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
export interface SecretaryEarningsSummary {
enabled: boolean;
share_percent: number;
today_rials: number;
this_month_rials: number;
total_rials: number;
appointments_count: number;
wallet_balance_rials: number;
}
/**
* خلاصهٔ درآمد منشی از نوبت‌های آنلاین. `enabled` تنها منبع تصمیم برای نمایش
* آیتم‌های مالی منشی است؛ سرور همان قاعده را اعمال می‌کند.
*/
export function useSecretaryEarnings(enabled = true) {
const { data, isLoading } = useQuery<ApiResponse<{ data: SecretaryEarningsSummary }>>({
queryKey: ['secretary-earnings-summary'],
queryFn: () => api.get('/api/v1/secretary/earnings/summary'),
staleTime: 5 * 60 * 1000,
enabled,
});
const summary = (data?.data as any)?.data ?? data?.data;
return { summary: summary as SecretaryEarningsSummary | undefined, isLoading };
}
+11 -2
View File
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Secretary, SecretaryPermissions } from '../types';
import { formatDate, maskMobile } from '../lib/utils';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
@@ -274,6 +275,11 @@ export default function SecretariesPage() {
{ key: 'mobile_number', header: 'موبایل', render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span> },
{ key: 'doctor_name', header: 'پزشک', render: (s) => s.doctor_name },
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
{ key: 'online_share_percent', header: 'سهم آنلاین', render: (s) => (
s.online_share_enabled
? <span style={{ fontSize: 12.5, color: 'var(--primary)', fontWeight: 700 }}>{formatNumber(s.online_share_percent ?? 0)}٪</span>
: <span className="muted"></span>
) },
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
];
@@ -309,6 +315,9 @@ export default function SecretariesPage() {
emptyMessage="هیچ منشی‌ای یافت نشد"
actions={(sec) => (
<>
<Link to={`/admin/secretaries/${sec.uuid}`} className="mini-btn" title="جزئیات و سهم درآمد">
<CurrencyDollarIcon style={{ width: 15, height: 15 }} />
</Link>
<button onClick={() => openEdit(sec)} className="mini-btn" title="ویرایش دسترسی‌ها">
<PencilIcon style={{ width: 15, height: 15 }} />
</button>
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import SecretaryDetailPage from './SecretaryDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
function mockSecretary(over: Record<string, unknown> = {}) {
get.mockResolvedValue({
success: true,
data: { data: {
uuid: 'rel-1', user_name: 'زهرا رضایی', mobile_number: '09120000000',
doctor_name: 'دکتر تست', doctor_uuid: 'doc-1', is_active: true,
permissions: {}, created_at: 1_700_000_000,
online_share_enabled: false, online_share_percent: 0,
earnings: { total_rials: 1_500_000, this_month_rials: 500_000, appointments_count: 3 },
...over,
} },
});
}
beforeEach(() => {
get.mockReset();
put.mockReset();
put.mockResolvedValue({ success: true, data: { data: {} } });
});
function renderDetail() {
return renderWithProviders(
<Routes><Route path="/admin/secretaries/:uuid" element={<SecretaryDetailPage />} /></Routes>,
{ route: '/admin/secretaries/rel-1' },
);
}
describe('SecretaryDetailPage', () => {
it('مشخصات منشی و خلاصهٔ درآمد را نشان می‌دهد', async () => {
mockSecretary();
renderDetail();
expect(await screen.findByText('اطلاعات منشی')).toBeInTheDocument();
expect(screen.getByText('دکتر تست')).toBeInTheDocument();
expect(screen.getByText('سهم درآمد نوبت‌های آنلاین')).toBeInTheDocument();
expect(screen.getByText('خلاصه درآمد')).toBeInTheDocument();
});
it('سوییچ و درصد را از سرور پیش‌پر می‌کند و ذخیره می‌فرستد', async () => {
mockSecretary({ online_share_enabled: true, online_share_percent: 5 });
renderDetail();
await waitFor(() => expect(screen.getByLabelText('درصد سهم منشی')).toHaveValue('5'));
expect(screen.getByLabelText('محاسبه درآمد منشی از نوبت‌های آنلاین')).toBeChecked();
fireEvent.change(screen.getByLabelText('درصد سهم منشی'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/admin/secretary/rel-1/online-share', {
enabled: true,
percent: 7,
}));
});
it('درصد بیشتر از ۱۰۰ خطا می‌دهد و ذخیره غیرفعال می‌شود', async () => {
mockSecretary({ online_share_enabled: true, online_share_percent: 5 });
renderDetail();
await waitFor(() => expect(screen.getByLabelText('درصد سهم منشی')).toHaveValue('5'));
fireEvent.change(screen.getByLabelText('درصد سهم منشی'), { target: { value: '101' } });
expect(screen.getByText('درصد نمی‌تواند بیشتر از ۱۰۰ باشد')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
expect(put).not.toHaveBeenCalled();
});
it('فعال‌سازی با درصد صفر مجاز نیست', async () => {
mockSecretary();
renderDetail();
await screen.findByText('اطلاعات منشی');
fireEvent.click(screen.getByLabelText('محاسبه درآمد منشی از نوبت‌های آنلاین'));
expect(screen.getByText('برای فعال‌سازی، درصد باید بیشتر از صفر باشد')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Secretary } from '../types';
import { digitsOnly, formatDate, formatNumber, formatRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
/** یک ردیف label:value با همان تم کارت‌های موجود. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, padding: '9px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13.5, color: 'var(--text)', fontWeight: 500 }}>{value}</span>
</div>
);
}
/**
* جزئیات یک رابطهٔ منشی–پزشک/کلینیک برای ادمین: مشخصات، سهم درآمد نوبت‌های آنلاین
* (فعال‌سازی + درصد) و خلاصهٔ درآمد.
*/
export default function SecretaryDetailPage() {
const { uuid = '' } = useParams();
const qc = useQueryClient();
const [enabled, setEnabled] = useState(false);
const [percent, setPercent] = useState('0');
const { data, isLoading } = useQuery<ApiResponse<{ data: Secretary }>>({
queryKey: ['admin-secretary', uuid],
queryFn: () => api.get(`/api/v1/admin/secretary/${uuid}`),
enabled: !!uuid,
});
const secretary = data?.data?.data;
useEffect(() => {
if (!secretary) return;
setEnabled(!!secretary.online_share_enabled);
setPercent(String(secretary.online_share_percent ?? 0));
}, [data]);
const save = useMutation({
mutationFn: () => api.put(`/api/v1/admin/secretary/${uuid}/online-share`, {
enabled,
percent: Number(percent) || 0,
}),
onSuccess: () => {
toast.success('سهم درآمد منشی ذخیره شد');
qc.invalidateQueries({ queryKey: ['admin-secretary', uuid] });
qc.invalidateQueries({ queryKey: ['secretaries'] });
},
onError: (e: Error) => toast.error(e.message),
});
const invalidPercent = Number(percent) > 100 || (enabled && Number(percent) <= 0);
if (isLoading || !secretary) {
return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
}
return (
<div className="fade-in">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
<Link to="/admin/secretaries" className="btn sm ghost" style={{ color: 'var(--text-2)' }}>
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
</Link>
</div>
<PageHeader title={secretary.user_name} description="جزئیات منشی و سهم درآمد نوبت‌های آنلاین" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--gap)' }}>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 10px' }}>اطلاعات منشی</h2>
<Row label="نام" value={secretary.user_name} />
<Row label="موبایل" value={<span dir="ltr">{secretary.mobile_number}</span>} />
<Row label="پزشک" value={secretary.doctor_name} />
{secretary.clinic_name && <Row label="کلینیک" value={secretary.clinic_name} />}
<Row label="وضعیت" value={
<span style={{ color: secretary.is_active ? 'var(--success)' : 'var(--text-3)', fontWeight: 700 }}>
{secretary.is_active ? 'فعال' : 'غیرفعال'}
</span>
} />
<Row label="تاریخ ثبت" value={formatDate(secretary.created_at)} />
</div>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 6px' }}>سهم درآمد نوبتهای آنلاین</h2>
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.9, color: 'var(--text-2)' }}>
درصد سهم از <b>مبلغ خالص</b> نوبت محاسبه می‌شود: ابتدا هزینهٔ پیامک و مالیات و
کسورات از مبلغ نوبت کم میشود، سپس این درصد اعمال میگردد. فقط نوبتهایی که
آنلاین ثبت و پرداخت میشوند سهم میسازند.
</p>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', marginBottom: 14 }}>
<span className="switch">
<input
type="checkbox"
aria-label="محاسبه درآمد منشی از نوبت‌های آنلاین"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>محاسبه درآمد از نوبتهای آنلاین فعال باشد</span>
</label>
<div className="form-row">
<label>درصد سهم منشی</label>
<input
className="input"
inputMode="numeric"
dir="ltr"
aria-label="درصد سهم منشی"
value={percent}
onChange={(e) => setPercent(digitsOnly(e.target.value, 3))}
placeholder="مثلاً: ۵"
/>
{Number(percent) > 100 && <p className="err-text">درصد نمیتواند بیشتر از ۱۰۰ باشد</p>}
{enabled && Number(percent) <= 0 && <p className="err-text">برای فعالسازی، درصد باید بیشتر از صفر باشد</p>}
</div>
<button
className="btn primary sm"
style={{ marginTop: 14 }}
disabled={save.isPending || invalidPercent}
onClick={() => save.mutate()}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 10px' }}>خلاصه درآمد</h2>
<Row label="کل درآمد" value={formatRial(secretary.earnings?.total_rials ?? 0)} />
<Row label="۳۰ روز گذشته" value={formatRial(secretary.earnings?.this_month_rials ?? 0)} />
<Row label="تعداد نوبت" value={formatNumber(secretary.earnings?.appointments_count ?? 0)} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import SecretaryEarningsPage from './SecretaryEarningsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const SUMMARY = {
enabled: true, share_percent: 5, relations: [],
today_rials: 500_000, this_month_rials: 3_000_000, total_rials: 9_000_000,
appointments_count: 4, wallet_balance_rials: 9_000_000,
};
const ROW = {
uuid: 'e1', appointment_uuid: 'ap1', doctor_name: 'دکتر تست',
gross_rials: 10_000_000, sms_fee_rials: 0, tax_rials: 0,
net_after_tax_rials: 10_000_000, share_percent: 5, share_rials: 500_000,
created_at: 1_700_000_000,
};
function mockEndpoints(summary: Record<string, unknown>, rows: object[] = []) {
get.mockImplementation((url?: string) => {
if (url === undefined) return Promise.resolve({ success: true, data: [] });
if (url.startsWith('/api/v1/secretary/earnings/summary')) {
return Promise.resolve({ success: true, data: { data: summary } });
}
if (url.startsWith('/api/v1/secretary/earnings/report')) {
return Promise.resolve({ success: true, data: rows, meta: { totalRecords: rows.length, totalPages: 1, currentPage: 1 } });
}
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => get.mockReset());
describe('SecretaryEarningsPage', () => {
it('کارت‌های درآمد و ردیف گزارش را نشان می‌دهد', async () => {
mockEndpoints(SUMMARY, [ROW]);
renderWithProviders(<SecretaryEarningsPage />);
expect(await screen.findByText('امروز')).toBeInTheDocument();
expect(screen.getByText('۳۰ روز گذشته')).toBeInTheDocument();
expect(screen.getByText('موجودی کیف پول')).toBeInTheDocument();
expect(await screen.findByText('دکتر تست')).toBeInTheDocument();
expect(screen.getByText('سهم شما')).toBeInTheDocument();
expect(screen.getByText('۵٪')).toBeInTheDocument();
});
it('غیرفعال بودن قابلیت → پیام و بدون جدول', async () => {
mockEndpoints({ ...SUMMARY, enabled: false });
renderWithProviders(<SecretaryEarningsPage />);
await waitFor(() => expect(screen.getByText(/برای شما فعال نیست/)).toBeInTheDocument());
expect(screen.queryByText('امروز')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, formatRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import StatCard from '../components/ui/StatCard';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import PersianDateInput from '../components/ui/PersianDateInput';
interface EarningsSummary {
enabled: boolean;
share_percent: number;
relations: { relation_uuid: string; doctor_name: string; clinic_name: string | null; share_percent: number }[];
today_rials: number;
this_month_rials: number;
total_rials: number;
appointments_count: number;
wallet_balance_rials: number;
}
interface EarningRow {
uuid: string;
appointment_uuid: string | null;
doctor_name: string | null;
gross_rials: number;
sms_fee_rials: number;
tax_rials: number;
net_after_tax_rials: number;
share_percent: number;
share_rials: number;
created_at: number;
}
/** تاریخ ISO (Y-m-d) → Unix؛ رشتهٔ خالی یعنی بدون فیلتر. */
const toUnix = (iso: string): string => (iso ? String(Math.floor(new Date(iso).getTime() / 1000)) : '');
/** درآمد منشی از نوبت‌های آنلاین: خلاصهٔ روزانه/ماهانه + گزارش سطر-به-سطر. */
export default function SecretaryEarningsPage() {
const [page, setPage] = useState(1);
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const limit = 15;
const { data: summaryData } = useQuery<ApiResponse<{ data: EarningsSummary }>>({
queryKey: ['secretary-earnings-summary'],
queryFn: () => api.get('/api/v1/secretary/earnings/summary'),
});
const summary = summaryData?.data?.data;
const { data, isLoading } = useQuery({
queryKey: ['secretary-earnings-report', page, from, to],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (from) params.set('from', toUnix(from));
if (to) params.set('to', toUnix(to));
return api.get<PaginatedResponse<EarningRow>>(`/api/v1/secretary/earnings/report?${params}`);
},
});
const rows = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const columns: Column<EarningRow>[] = [
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name ?? '—' },
{ key: 'gross_rials', header: 'مبلغ نوبت', render: (r) => formatRial(r.gross_rials) },
{ key: 'sms_fee_rials', header: 'هزینه پیامک', render: (r) => (r.sms_fee_rials > 0 ? formatRial(r.sms_fee_rials) : '—') },
{ key: 'tax_rials', header: 'مالیات', render: (r) => (r.tax_rials > 0 ? formatRial(r.tax_rials) : '—') },
{ key: 'net_after_tax_rials', header: 'مبلغ خالص', render: (r) => formatRial(r.net_after_tax_rials) },
{ key: 'share_percent', header: 'درصد', render: (r) => `${formatNumber(r.share_percent)}٪` },
{ key: 'share_rials', header: 'سهم شما', render: (r) => (
<b style={{ color: 'var(--success)' }}>{formatRial(r.share_rials)}</b>
) },
];
return (
<div className="fade-in">
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند" />
{summary && !summary.enabled ? (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
محاسبه درآمد از نوبتهای آنلاین برای شما فعال نیست. برای فعالسازی با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="امروز" value={formatRial(summary?.today_rials ?? 0)} tone="green" />
<StatCard label="۳۰ روز گذشته" value={formatRial(summary?.this_month_rials ?? 0)} tone="violet" />
<StatCard label="کل درآمد" value={formatRial(summary?.total_rials ?? 0)} tone="amber" />
<StatCard label="موجودی کیف پول" value={formatRial(summary?.wallet_balance_rials ?? 0)} tone="pink" />
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar" style={{ gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 170 }}>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>از تاریخ</label>
<PersianDateInput value={from} onChange={(v) => { setFrom(v); setPage(1); }} placeholder="انتخاب" />
</div>
<div style={{ minWidth: 170 }}>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تا تاریخ</label>
<PersianDateInput value={to} onChange={(v) => { setTo(v); setPage(1); }} placeholder="انتخاب" />
</div>
<div style={{ marginInlineStart: 'auto', fontSize: 12.5, color: 'var(--text-2)' }}>
{formatNumber(summary?.appointments_count ?? 0)} نوبت
</div>
</div>
</div>
<DataTable<EarningRow>
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="هنوز درآمدی از نوبت آنلاین ثبت نشده است"
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrashIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDate, formatRial, tomanToRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import StatCard from '../components/ui/StatCard';
import SearchableSelect from '../components/ui/SearchableSelect';
import PriceInput from '../components/ui/PriceInput';
import DataTable, { Column } from '../components/ui/DataTable';
interface IbanItem { id: string; iban: string; bank_name: string | null; owner_name: string | null; verified: boolean }
interface SecretaryMe { bank_account: IbanItem[] | null }
interface SettlementRow {
uuid: string;
amount_rials: number;
status: string;
created_at: number;
bank_account?: { iban?: string } | null;
}
const STATUS_LABEL: Record<string, string> = {
pending: 'در انتظار بررسی',
approved: 'تأییدشده',
rejected: 'رد شده',
paid: 'پرداخت‌شده',
};
/** unwrap پاسخ‌های احتمالاً تودرتوی `success(['data' => …])`. */
const unwrap = <T,>(res: ApiResponse<T> | undefined): T | undefined =>
((res?.data as any)?.data ?? res?.data) as T | undefined;
/** تسویه حساب منشی: موجودی، مدیریت شبا و درخواست برداشت — الگوی پنل نماینده. */
export default function SecretarySettlementPage() {
const qc = useQueryClient();
const [amountToman, setAmountToman] = useState(0);
const [ibanId, setIbanId] = useState('');
const [newIban, setNewIban] = useState('');
const [bankName, setBankName] = useState('');
const balanceQ = useQuery<ApiResponse<{ balance_rials: number }>>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/api/v1/wallet/balance'),
});
const balance = unwrap(balanceQ.data)?.balance_rials ?? 0;
const meQ = useQuery<ApiResponse<SecretaryMe>>({
queryKey: ['secretary-me'],
queryFn: () => api.get('/api/v1/secretary/me'),
});
const ibans = unwrap(meQ.data)?.bank_account ?? [];
const verifiedIbans = ibans.filter((b) => b.verified);
const listQ = useQuery<ApiResponse<SettlementRow[]>>({
queryKey: ['secretary-settlements'],
queryFn: () => api.get('/api/v1/settlement?page=1&limit=15'),
});
const settlements = unwrap(listQ.data) ?? [];
const invalidateWallet = () => {
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
qc.invalidateQueries({ queryKey: ['secretary-settlements'] });
};
const addIbanMut = useMutation({
mutationFn: () => api.post('/api/v1/secretary/iban', {
iban: newIban.trim().toUpperCase(),
...(bankName.trim() ? { bank_name: bankName.trim() } : {}),
}),
onSuccess: () => {
toast.success('شماره شبا ثبت شد؛ پس از تأیید ادمین قابل استفاده است');
setNewIban('');
setBankName('');
qc.invalidateQueries({ queryKey: ['secretary-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const removeIbanMut = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/secretary/iban/${id}`),
onSuccess: () => {
toast.success('شماره شبا حذف شد');
qc.invalidateQueries({ queryKey: ['secretary-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const requestMut = useMutation({
mutationFn: () => api.post('/api/v1/settlement', {
amount_rials: tomanToRial(amountToman),
iban_id: ibanId,
}),
onSuccess: () => {
toast.success('درخواست تسویه ثبت شد');
setAmountToman(0);
invalidateWallet();
},
onError: (e: Error) => toast.error(e.message),
});
const columns: Column<SettlementRow>[] = [
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'amount_rials', header: 'مبلغ', render: (r) => formatRial(r.amount_rials) },
{ key: 'bank_account', header: 'شبا', render: (r) => <span dir="ltr">{r.bank_account?.iban ?? '—'}</span> },
{ key: 'status', header: 'وضعیت', render: (r) => STATUS_LABEL[r.status] ?? r.status },
];
return (
<div className="fade-in">
<PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
</div>
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>شماره شبا</h2>
{ibans.length === 0 ? (
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '0 0 12px' }}>هنوز شبایی ثبت نکردهاید.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{ibans.map((b) => (
<div key={b.id} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}>
<span dir="ltr" style={{ flex: 1, fontSize: 13 }}>{b.iban}</span>
{b.bank_name && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{b.bank_name}</span>}
<span style={{ fontSize: 11.5, fontWeight: 700, color: b.verified ? 'var(--success)' : 'var(--warning)' }}>
{b.verified ? 'تأییدشده' : 'در انتظار تأیید'}
</span>
<button
className="mini-btn danger"
title="حذف"
disabled={removeIbanMut.isPending}
onClick={() => removeIbanMut.mutate(b.id)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</div>
))}
</div>
)}
{ibans.length < 2 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label>شماره شبا</label>
<input
className="input"
dir="ltr"
aria-label="شماره شبا"
placeholder="IR..."
value={newIban}
onChange={(e) => setNewIban(e.target.value)}
/>
</div>
<div className="form-row">
<label>نام بانک (اختیاری)</label>
<input className="input" aria-label="نام بانک" value={bankName} onChange={(e) => setBankName(e.target.value)} />
</div>
<button
className="btn primary sm"
disabled={addIbanMut.isPending || newIban.trim() === ''}
onClick={() => addIbanMut.mutate()}
>
{addIbanMut.isPending ? 'در حال ثبت...' : 'افزودن شبا'}
</button>
</div>
)}
</div>
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>درخواست تسویه</h2>
{verifiedIbans.length === 0 ? (
<p style={{ fontSize: 12.5, color: 'var(--text-2)', margin: 0, lineHeight: 1.9 }}>
برای ثبت درخواست تسویه، باید حداقل یک شماره شبای <b>تأییدشده</b> داشته باشید.
</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label>مبلغ (تومان)</label>
<PriceInput value={amountToman} onChange={setAmountToman} suffix="تومان" />
</div>
<div className="form-row">
<label>شماره شبا</label>
<SearchableSelect
options={verifiedIbans.map((b) => ({ value: b.id, label: `${b.iban}${b.bank_name ? `${b.bank_name}` : ''}` }))}
value={ibanId || null}
onChange={(v) => setIbanId(v ? String(v) : '')}
placeholder="انتخاب شبا"
/>
</div>
<button
className="btn primary sm"
disabled={requestMut.isPending || amountToman <= 0 || ibanId === ''}
onClick={() => requestMut.mutate()}
>
{requestMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
</button>
</div>
)}
</div>
<div className="card">
<DataTable<SettlementRow>
columns={columns}
data={settlements}
loading={listQ.isLoading}
emptyMessage="درخواست تسویه‌ای ثبت نشده است"
/>
</div>
</div>
);
}
+10
View File
@@ -485,6 +485,16 @@ export interface Secretary {
address?: string | null;
permissions: SecretaryPermissions;
created_at: string;
clinic_uuid?: string | null;
clinic_name?: string | null;
/** سهم منشی از نوبت‌های آنلاین — per-relation، توسط ادمین تنظیم می‌شود. */
online_share_enabled?: boolean;
online_share_percent?: number;
earnings?: {
total_rials: number;
this_month_rials: number;
appointments_count: number;
};
}
export interface SecretaryPermissions {
+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` لاگ می‌شود تا سهم سیستم منفی نشود.
---
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Per-relation share a secretary earns from that doctor's/clinic's online appointments.
* Defaults keep the current behaviour: disabled, zero percent.
*/
final class Version20260725143211 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add doctor_secretaries.online_share_enabled and online_share_percent';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE doctor_secretaries ADD online_share_enabled TINYINT DEFAULT 0 NOT NULL, ADD online_share_percent NUMERIC(5, 2) DEFAULT '0.00' NOT NULL");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE doctor_secretaries DROP online_share_enabled, DROP online_share_percent');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* User-level IBANs (settlement payouts) — the same JSON shape representations use,
* so any role with a wallet balance can request a settlement.
*/
final class Version20260725143539 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add profiles.bank_account (user IBANs for settlements)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE profiles ADD bank_account JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE profiles DROP bank_account');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Secretary share of an online appointment: the total on the payment's financial
* breakdown, plus one row per secretary so the panel can report per user.
*/
final class Version20260725144018 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add secretary_earnings and financial_breakdowns.secretary_share_rials';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE secretary_earnings (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, relation_uuid VARCHAR(36) NOT NULL, share_percent NUMERIC(5, 2) NOT NULL, share_rials INT NOT NULL, created_at INT NOT NULL, breakdown_id INT NOT NULL, secretary_user_id INT NOT NULL, UNIQUE INDEX UNIQ_8F257C76D17F50A6 (uuid), INDEX IDX_8F257C7667F54C40 (breakdown_id), INDEX IDX_8F257C7658E93B3D (secretary_user_id), INDEX idx_secretary_earnings_user_time (secretary_user_id, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE secretary_earnings ADD CONSTRAINT FK_8F257C7667F54C40 FOREIGN KEY (breakdown_id) REFERENCES financial_breakdowns (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE secretary_earnings ADD CONSTRAINT FK_8F257C7658E93B3D FOREIGN KEY (secretary_user_id) REFERENCES users (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE financial_breakdowns ADD secretary_share_rials INT DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE secretary_earnings DROP FOREIGN KEY FK_8F257C7667F54C40');
$this->addSql('ALTER TABLE secretary_earnings DROP FOREIGN KEY FK_8F257C7658E93B3D');
$this->addSql('DROP TABLE secretary_earnings');
$this->addSql('ALTER TABLE financial_breakdowns DROP secretary_share_rials');
}
}
@@ -46,6 +46,8 @@ class AdminApiController extends BaseController
private readonly \App\Patient\Service\PatientResolver $patientResolver,
private readonly \App\Insurance\Service\VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation,
private readonly \App\Secretary\Repository\DoctorSecretaryRepository $doctorSecretaryRepo,
private readonly \App\Secretary\Repository\SecretaryEarningRepository $secretaryEarningRepo,
) {}
// ── Users ─────────────────────────────────────────────────────────────────
@@ -1500,6 +1502,7 @@ class AdminApiController extends BaseController
$qb = $this->em->createQueryBuilder()
->select(
'ds.uuid, ds.permissions, ds.active, ds.createdAt',
'ds.onlineShareEnabled, ds.onlineSharePercent',
'u.mobileNumber as mobile, u.realName as user_name',
'd.name as doctor_name, d.uuid as doctor_uuid',
)
@@ -1525,6 +1528,8 @@ class AdminApiController extends BaseController
'doctor_name' => $ds['doctor_name'],
'doctor_uuid' => $ds['doctor_uuid'],
'is_active' => (bool) $ds['active'],
'online_share_enabled' => (bool) $ds['onlineShareEnabled'],
'online_share_percent' => (float) $ds['onlineSharePercent'],
'permissions' => $ds['permissions'] ?? DoctorSecretary::DEFAULT_PERMISSIONS,
'created_at' => date('c', (int) $ds['createdAt']),
], $rows);
@@ -1532,6 +1537,75 @@ class AdminApiController extends BaseController
return $this->paginated($items, (int) $total, $page, $limit);
}
// ── Secretary detail + online-appointment share ───────────────────────────
#[OA\Get(
path: '/api/v1/admin/secretary/{uuid}',
summary: 'Secretary relation detail with its online-appointment share settings and earnings',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 200, description: 'Secretary detail'),
new OA\Response(response: 404, description: 'Secretary not found'),
]
)]
#[Route('/api/v1/admin/secretary/{uuid}', methods: ['GET'])]
public function secretaryDetail(string $uuid): JsonResponse
{
$relation = $this->doctorSecretaryRepo->findByUuid($uuid);
if ($relation === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منشی یافت نشد', 404);
}
$secretary = $relation->getSecretary();
$monthStart = time() - 30 * 86_400;
return $this->success([
'data' => $relation->toArray() + [
'clinic_name' => $relation->getClinic()?->getName(),
'earnings' => [
'total_rials' => $this->secretaryEarningRepo->sumFor($secretary),
'this_month_rials' => $this->secretaryEarningRepo->sumFor($secretary, $monthStart),
'appointments_count' => $this->secretaryEarningRepo->countFor($secretary),
],
],
]);
}
#[OA\Put(
path: '/api/v1/admin/secretary/{uuid}/online-share',
summary: 'Enable/disable the secretary share of online appointments and set its percent',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 200, description: 'Saved'),
new OA\Response(response: 404, description: 'Secretary not found'),
new OA\Response(response: 422, description: 'Invalid percent'),
]
)]
#[Route('/api/v1/admin/secretary/{uuid}/online-share', methods: ['PUT'])]
public function saveSecretaryOnlineShare(string $uuid, Request $request): JsonResponse
{
$relation = $this->doctorSecretaryRepo->findByUuid($uuid);
if ($relation === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منشی یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$enabled = (bool) ($data['enabled'] ?? false);
$percent = (float) ($data['percent'] ?? 0);
if ($percent < 0 || $percent > 100) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درصد سهم باید بین ۰ تا ۱۰۰ باشد', 422, 'percent');
}
if ($enabled && $percent <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای فعال‌سازی، درصد سهم باید بیشتر از صفر باشد', 422, 'percent');
}
$relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent);
$this->doctorSecretaryRepo->save($relation);
return $this->success(['data' => $relation->toArray()]);
}
// ── Ratings ───────────────────────────────────────────────────────────────
#[OA\Get(
+3 -47
View File
@@ -8,12 +8,15 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use App\Representation\Repository\RepresentationRepository;
use App\Shared\Entity\HasIbansTrait;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: RepresentationRepository::class)]
#[ORM\Table(name: 'representations')]
class Representation
{
use HasIbansTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -124,53 +127,6 @@ class Representation
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @return array<int,array<string,mixed>> */
public function getIbans(): array { return $this->bankAccount ?? []; }
/**
* افزودن یک شبا (حداکثر ۲). id خودکار تولید می‌شود.
* @param array{iban:string,bank_name:?string,owner_name:?string,verified?:bool} $iban
*/
public function addIban(array $iban): self
{
$ibans = $this->getIbans();
if (count($ibans) >= 2) {
throw new \DomainException('iban_limit');
}
$ibans[] = [
'id' => Uuid::v4()->toRfc4122(),
'iban' => $iban['iban'],
'bank_name' => $iban['bank_name'] ?? null,
'owner_name' => $iban['owner_name'] ?? null,
'verified' => $iban['verified'] ?? false,
'created_at' => time(),
];
$this->bankAccount = $ibans;
$this->touch();
return $this;
}
public function removeIban(string $id): self
{
$this->bankAccount = array_values(array_filter(
$this->getIbans(),
fn(array $i) => ($i['id'] ?? null) !== $id
));
$this->touch();
return $this;
}
/** @return array<string,mixed>|null یک شبای تأییدشده با این id */
public function findVerifiedIban(string $id): ?array
{
foreach ($this->getIbans() as $iban) {
if (($iban['id'] ?? null) === $id && ($iban['verified'] ?? false)) {
return $iban;
}
}
return null;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
@@ -35,9 +35,175 @@ class SecretaryController extends BaseController
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly SecretaryService $secretaryService,
private readonly \App\Secretary\Repository\SecretaryEarningRepository $earningRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly string $appUrl,
) {}
// ── درآمد منشی از نوبت‌های آنلاین ─────────────────────────────────────────
/**
* رابطه‌های فعالی که سهم نوبت آنلاین برایشان روشن است.
*
* @return DoctorSecretary[]
*/
private function shareRelationsOf(User $user): array
{
return array_values(array_filter(
$this->secretaryRepo->findAllActiveBySecretary($user),
static fn(DoctorSecretary $r) => $r->effectiveOnlineSharePercent() > 0,
));
}
#[OA\Get(
path: '/api/v1/secretary/earnings/summary',
summary: 'Secretary earnings summary (today / last 30 days / total) from online appointments',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'Earnings summary')]
)]
#[Route('/api/v1/secretary/earnings/summary', methods: ['GET'])]
public function earningsSummary(#[CurrentUser] User $user): JsonResponse
{
$relations = $this->shareRelationsOf($user);
$today = strtotime('today');
return $this->success([
'data' => [
// false یعنی این قابلیت برای هیچ رابطه‌ای فعال نیست؛ پنل پیام مناسب نشان می‌دهد.
'enabled' => $relations !== [],
'share_percent' => $relations !== [] ? $relations[0]->effectiveOnlineSharePercent() : 0.0,
'relations' => array_map(static fn(DoctorSecretary $r) => [
'relation_uuid' => $r->getUuid(),
'doctor_name' => $r->getDoctor()->getName(),
'clinic_name' => $r->getClinic()?->getName(),
'share_percent' => $r->effectiveOnlineSharePercent(),
], $relations),
'today_rials' => $this->earningRepo->sumFor($user, $today),
'this_month_rials' => $this->earningRepo->sumFor($user, time() - 30 * 86_400),
'total_rials' => $this->earningRepo->sumFor($user),
'appointments_count' => $this->earningRepo->countFor($user),
'wallet_balance_rials' => $this->settlementRepo->getWalletBalance($user),
],
]);
}
#[OA\Get(
path: '/api/v1/secretary/earnings/report',
summary: 'Paginated per-appointment earnings report of the current secretary',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [new OA\Response(response: 200, description: 'Paginated earnings rows')]
)]
#[Route('/api/v1/secretary/earnings/report', methods: ['GET'])]
public function earningsReport(Request $request, #[CurrentUser] User $user): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$from = $request->query->get('from');
$to = $request->query->get('to');
$report = $this->earningRepo->reportFor(
$user,
$page,
$limit,
($from !== null && $from !== '') ? (int) $from : null,
($to !== null && $to !== '') ? (int) $to : null,
);
return $this->paginated($report['items'], $report['total'], $page, $limit);
}
// ── شماره شبای منشی (برای تسویه) ──────────────────────────────────────────
#[OA\Post(
path: '/api/v1/secretary/iban',
summary: 'Add an IBAN (max 2) to the current secretary profile',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 201, description: 'IBAN added'),
new OA\Response(response: 422, description: 'Invalid IBAN or limit reached'),
]
)]
#[Route('/api/v1/secretary/iban', methods: ['POST'])]
public function addIban(Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$iban = strtoupper(preg_replace('/\s+/', '', (string) ($data['iban'] ?? '')));
if (!preg_match('/^IR\d{24}$/', $iban)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر است (IR و ۲۴ رقم)', 422, 'iban');
}
$profile = $this->profileRepo->findByUser($user) ?? new \App\UserProfile\Entity\UserProfile($user);
try {
// verified فقط از سمت ادمین ست می‌شود؛ تسویه تنها با شبای تأییدشده مجاز است.
$profile->addIban([
'iban' => $iban,
'bank_name' => isset($data['bank_name']) ? trim((string) $data['bank_name']) : null,
'owner_name' => isset($data['owner_name']) ? trim((string) $data['owner_name']) : null,
]);
} catch (\DomainException) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'حداکثر دو شماره شبا مجاز است', 422, 'iban');
}
$this->profileRepo->save($profile);
return $this->success(['data' => ['bank_account' => $profile->getIbans()]], 201);
}
#[OA\Delete(
path: '/api/v1/secretary/iban/{id}',
summary: 'Remove one of the current secretary IBANs',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'IBAN removed')]
)]
#[Route('/api/v1/secretary/iban/{id}', methods: ['DELETE'])]
public function removeIban(string $id, #[CurrentUser] User $user): JsonResponse
{
$profile = $this->profileRepo->findByUser($user);
if ($profile === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'شماره شبا یافت نشد', 404);
}
$this->profileRepo->save($profile->removeIban($id));
return $this->success(['data' => ['bank_account' => $profile->getIbans()]]);
}
#[OA\Get(
path: '/api/v1/secretary/me',
summary: 'Current secretary profile: relations, share settings and IBANs',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'Secretary profile')]
)]
#[Route('/api/v1/secretary/me', methods: ['GET'])]
public function me(#[CurrentUser] User $user): JsonResponse
{
$relations = $this->secretaryRepo->findAllActiveBySecretary($user);
return $this->success([
'data' => [
'full_name' => $user->getRealName(),
'mobile' => $user->getMobileNumber(),
'bank_account' => $this->profileRepo->findByUser($user)?->getIbans() ?? [],
'relations' => array_map(static fn(DoctorSecretary $r) => [
'relation_uuid' => $r->getUuid(),
'doctor_name' => $r->getDoctor()->getName(),
'clinic_name' => $r->getClinic()?->getName(),
'online_share_enabled' => $r->isOnlineShareEnabled(),
'online_share_percent' => $r->getOnlineSharePercent(),
], $relations),
],
]);
}
#[Route('/api/v1/secretary', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
{
+22
View File
@@ -73,6 +73,17 @@ class DoctorSecretary
#[ORM\Column(type: 'boolean')]
private bool $active = true;
/**
* سهم منشی از نوبت‌های آنلاینِ همین پزشک/کلینیک فعال است؟ تنظیم per-relation است:
* یک منشی می‌تواند برای یک پزشک سهم داشته باشد و برای دیگری نه.
*/
#[ORM\Column(name: 'online_share_enabled', type: 'boolean', options: ['default' => false])]
private bool $onlineShareEnabled = false;
/** درصد سهم منشی از «خالصِ پس از مالیات» نوبت آنلاین. */
#[ORM\Column(name: 'online_share_percent', type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
private string $onlineSharePercent = '0.00';
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -101,6 +112,13 @@ class DoctorSecretary
public function getNationalCode(): ?string { return $this->nationalCode; }
public function getAddress(): ?string { return $this->address; }
public function isActive(): bool { return $this->active; }
public function isOnlineShareEnabled(): bool { return $this->onlineShareEnabled; }
public function getOnlineSharePercent(): float { return (float) $this->onlineSharePercent; }
/** سهم مؤثر: درصد فقط وقتی معنا دارد که رابطه فعال و سهم روشن باشد. */
public function effectiveOnlineSharePercent(): float
{
return ($this->active && $this->onlineShareEnabled) ? (float) $this->onlineSharePercent : 0.0;
}
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -108,6 +126,8 @@ class DoctorSecretary
public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; }
public function setNationalCode(?string $v): self { $this->nationalCode = $v; $this->touch(); return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
public function setOnlineShareEnabled(bool $v): self { $this->onlineShareEnabled = $v; $this->touch(); return $this; }
public function setOnlineSharePercent(float $v): self { $this->onlineSharePercent = (string) $v; $this->touch(); return $this; }
/** Deep merge: only provided resources/actions are updated */
public function mergePermissions(array $patch): void
@@ -144,6 +164,8 @@ class DoctorSecretary
'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(),
'is_active' => $this->active,
'online_share_enabled' => $this->onlineShareEnabled,
'online_share_percent' => (float) $this->onlineSharePercent,
'national_code' => $this->nationalCode,
'address' => $this->address,
'permissions' => $this->getPermissions()['resources'] ?? $this->getPermissions(),
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Secretary\Entity;
use App\Auth\Entity\User;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Settlement\Entity\FinancialBreakdown;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* سهم یک منشی از یک نوبت آنلاین.
*
* جدول جداست (نه JSON روی FinancialBreakdown) چون گزارش پنل منشی باید per-user
* فیلتر و جمع‌بندی شود؛ یک پرداخت می‌تواند چند منشیِ سهم‌بر داشته باشد.
*/
#[ORM\Entity(repositoryClass: SecretaryEarningRepository::class)]
#[ORM\Table(name: 'secretary_earnings')]
#[ORM\Index(columns: ['secretary_user_id', 'created_at'], name: 'idx_secretary_earnings_user_time')]
class SecretaryEarning
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: FinancialBreakdown::class)]
#[ORM\JoinColumn(name: 'breakdown_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private FinancialBreakdown $breakdown;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'secretary_user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private User $secretary;
/** رابطهٔ منشی–پزشک/کلینیکی که سهم از آن آمده (snapshot، برای ردگیری). */
#[ORM\Column(name: 'relation_uuid', type: 'string', length: 36)]
private string $relationUuid;
#[ORM\Column(name: 'share_percent', type: 'decimal', precision: 5, scale: 2)]
private string $sharePercent;
#[ORM\Column(name: 'share_rials', type: 'integer')]
private int $shareRials;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(
FinancialBreakdown $breakdown,
User $secretary,
string $relationUuid,
float $sharePercent,
int $shareRials,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->breakdown = $breakdown;
$this->secretary = $secretary;
$this->relationUuid = $relationUuid;
$this->sharePercent = number_format($sharePercent, 2, '.', '');
$this->shareRials = $shareRials;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getBreakdown(): FinancialBreakdown { return $this->breakdown; }
public function getSecretary(): User { return $this->secretary; }
public function getRelationUuid(): string { return $this->relationUuid; }
public function getSharePercent(): float { return (float) $this->sharePercent; }
public function getShareRials(): int { return $this->shareRials; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'relation_uuid' => $this->relationUuid,
'share_percent' => (float) $this->sharePercent,
'share_rials' => $this->shareRials,
'created_at' => $this->createdAt,
];
}
}
@@ -163,6 +163,36 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
->getResult();
}
/**
* رابطه‌های فعالی که سهم درآمد نوبت آنلاین برایشان روشن است — برای کلینیک همهٔ
* منشی‌های همان کلینیک، برای مطب شخصی منشی‌های همان پزشک.
*
* @return DoctorSecretary[]
*/
public function findOnlineShareRows(Doctor $doctor, ?Clinic $clinic): array
{
$qb = $this->createQueryBuilder('s')
->addSelect('sec')
->join('s.secretary', 'sec')
->where('s.active = true')
->andWhere('s.onlineShareEnabled = true')
->andWhere('s.onlineSharePercent > 0');
if ($clinic !== null) {
$qb->andWhere('s.clinic = :clinic')
->andWhere('s.ownerType = :type')
->setParameter('clinic', $clinic)
->setParameter('type', DoctorSecretary::OWNER_CLINIC);
} else {
$qb->andWhere('s.doctor = :doctor')
->andWhere('s.ownerType = :type')
->setParameter('doctor', $doctor)
->setParameter('type', DoctorSecretary::OWNER_DOCTOR);
}
return $qb->getQuery()->getResult();
}
public function save(DoctorSecretary $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -0,0 +1,102 @@
<?php
namespace App\Secretary\Repository;
use App\Auth\Entity\User;
use App\Secretary\Entity\SecretaryEarning;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SecretaryEarningRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SecretaryEarning::class);
}
public function save(SecretaryEarning $earning, bool $flush = true): void
{
$this->getEntityManager()->persist($earning);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/** مجموع سهم یک منشی در یک بازه؛ بدون بازه = کل. */
public function sumFor(User $secretary, ?int $from = null, ?int $to = null): int
{
$qb = $this->createQueryBuilder('e')
->select('COALESCE(SUM(e.shareRials), 0)')
->where('e.secretary = :user')
->setParameter('user', $secretary);
if ($from !== null) {
$qb->andWhere('e.createdAt >= :from')->setParameter('from', $from);
}
if ($to !== null) {
$qb->andWhere('e.createdAt <= :to')->setParameter('to', $to);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countFor(User $secretary): int
{
return (int) $this->createQueryBuilder('e')
->select('COUNT(e.id)')
->where('e.secretary = :user')
->setParameter('user', $secretary)
->getQuery()
->getSingleScalarResult();
}
/**
* گزارش سطر-به-سطر برای پنل منشی: هر ردیف با تفکیک مالیِ همان پرداخت و نوبت.
*
* @return array{items: list<array<string, mixed>>, total: int}
*/
public function reportFor(User $secretary, int $page, int $limit, ?int $from = null, ?int $to = null): array
{
$qb = $this->createQueryBuilder('e')
->select(
'e.uuid, e.sharePercent, e.shareRials, e.createdAt,
b.grossRials, b.smsFeeRials, b.taxRials, b.netAfterTaxRials,
a.uuid AS appointment_uuid, doc.name AS doctor_name'
)
->join('e.breakdown', 'b')
->join('b.payment', 'p')
->leftJoin('p.appointment', 'a')
->leftJoin('a.doctor', 'doc')
->where('e.secretary = :user')
->setParameter('user', $secretary)
->orderBy('e.createdAt', 'DESC');
if ($from !== null) {
$qb->andWhere('e.createdAt >= :from')->setParameter('from', $from);
}
if ($to !== null) {
$qb->andWhere('e.createdAt <= :to')->setParameter('to', $to);
}
$total = (int) (clone $qb)->select('COUNT(e.id)')->resetDQLPart('orderBy')
->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
->getQuery()->getArrayResult();
$items = array_map(static fn(array $r) => [
'uuid' => $r['uuid'],
'appointment_uuid' => $r['appointment_uuid'] ?? null,
'doctor_name' => $r['doctor_name'] ?? null,
'gross_rials' => (int) $r['grossRials'],
'sms_fee_rials' => (int) $r['smsFeeRials'],
'tax_rials' => (int) $r['taxRials'],
'net_after_tax_rials' => (int) $r['netAfterTaxRials'],
'share_percent' => (float) $r['sharePercent'],
'share_rials' => (int) $r['shareRials'],
'created_at' => (int) $r['createdAt'],
], $rows);
return ['items' => $items, 'total' => $total];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Secretary\Service;
use App\Payment\Entity\Payment;
use App\Secretary\Repository\DoctorSecretaryRepository;
/**
* منشی‌هایی که از یک نوبت **آنلاین** سهم می‌برند.
*
* «آنلاین» یعنی همین مسیر: تقسیم مالی تنها از `PaymentManager` (پرداخت موفق درگاه)
* صدا زده می‌شود؛ نوبتی که در پنل ثبت و قطعی می‌شود از این مسیر عبور نمی‌کند و سهمی
* نمی‌سازد. انتساب بر پایهٔ محیط نوبت است: کلینیک نوبت، وگرنه خودِ پزشک.
*/
class SecretaryShareResolver
{
public function __construct(
private readonly DoctorSecretaryRepository $secretaryRepo,
) {}
/**
* @return list<array{user: \App\Auth\Entity\User, percent: float, relation_uuid: string}>
*/
public function for(Payment $payment): array
{
$appointment = $payment->getAppointment();
if ($appointment === null) {
return [];
}
$rows = $this->secretaryRepo->findOnlineShareRows($appointment->getDoctor(), $appointment->getClinic());
return array_values(array_map(static fn($row) => [
'user' => $row->getSecretary(),
'percent' => $row->effectiveOnlineSharePercent(),
'relation_uuid' => $row->getUuid(),
], $rows));
}
}
@@ -24,7 +24,7 @@ class SettlementController extends BaseController
public function __construct(
private readonly SettlementRepository $settlementRepo,
private readonly WalletTransactionRepository $walletRepo,
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
private readonly \App\Settlement\Service\UserIbanResolver $ibanResolver,
private readonly \App\Shared\Service\FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
@@ -155,8 +155,8 @@ class SettlementController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب شماره شبا الزامی است', 422, 'iban_id');
}
$rep = $this->representationRepo->findByUser($user);
$iban = $rep?->findVerifiedIban($ibanId);
// شبا از هر منبعی که کاربر دارد: نماینده یا پروفایل کاربر (منشی و بقیهٔ نقش‌ها).
$iban = $this->ibanResolver->findVerifiedIban($user, $ibanId);
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id');
}
@@ -57,6 +57,13 @@ class FinancialBreakdown
#[ORM\Column(name: 'system_share_rials', type: 'integer')]
private int $systemShareRials;
/**
* مجموع سهم منشی‌ها از همین پرداخت. تفکیک هر منشی در
* {@see \App\Secretary\Entity\SecretaryEarning} ذخیره می‌شود (قابل کوئری برای گزارش).
*/
#[ORM\Column(name: 'secretary_share_rials', type: 'integer', options: ['default' => 0])]
private int $secretaryShareRials = 0;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
@@ -112,6 +119,8 @@ class FinancialBreakdown
public function getPayment(): Payment { return $this->payment; }
public function getSource(): string { return $this->source; }
public function getRepresentationId(): ?int { return $this->representationId; }
public function getSecretaryShareRials(): int { return $this->secretaryShareRials; }
public function setSecretaryShareRials(int $v): self { $this->secretaryShareRials = $v; return $this; }
public function toArray(): array
{
@@ -127,6 +136,7 @@ class FinancialBreakdown
'net_after_tax_rials' => $this->netAfterTaxRials,
'commission_percent' => $this->commissionPercent,
'representation_share_rials' => $this->representationShareRials,
'secretary_share_rials' => $this->secretaryShareRials,
'system_share_rials' => $this->systemShareRials,
'representation_id' => $this->representationId,
'doctor_id' => $this->doctorId,
+100 -20
View File
@@ -2,16 +2,21 @@
namespace App\Settlement\Service;
use App\Auth\Entity\User;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Secretary\Entity\SecretaryEarning;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Secretary\Service\SecretaryShareResolver;
use App\Settlement\Entity\FinancialBreakdown;
use App\Settlement\Entity\WalletTransaction;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Settlement\Repository\SettlementRepository;
use App\Settlement\Repository\WalletTransactionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* موتور تقسیم مالی پس از پرداخت موفق.
@@ -25,33 +30,49 @@ class CommissionService
private readonly SettlementRepository $settlementRepo,
private readonly WalletTransactionRepository $walletRepo,
private readonly FinancialBreakdownRepository $breakdownRepo,
private readonly SecretaryShareResolver $secretaryShares,
private readonly SecretaryEarningRepository $earningRepo,
private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $em,
) {}
/**
* پورسانت نوبت: درصد = commission_percent همان نماینده.
* گاردِ دامنه: فقط وقتی که پزشک متعلق به نماینده باشد و نوبت هم از دامنه‌ی همان نماینده ثبت شده باشد.
* تقسیم مالی نوبت آنلاین: پورسانت نماینده (اگر گاردِ دامنه برقرار باشد) و سهم
* منشی‌های همان پزشک/کلینیک — هر کدام مستقل. سهم منشی به وجود نماینده گره نیست.
*/
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
// پرداخت دوبار پردازش نشود — قبل از هر اعتبارِ کیف پول.
if ($this->breakdownRepo->existsForPayment($payment)) return;
// هر دو شرط لازم است و باید یکی باشند.
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
$rep = $this->eligibleAppointmentRep($doctorRepId, $bookingRepId);
$secretaries = $this->secretaryShares->for($payment);
$rep = $this->resolveRep($doctorRepId);
if ($rep === null) return;
if ($rep === null && $secretaries === []) return;
$this->settle(
$payment,
FinancialBreakdown::SOURCE_APPOINTMENT,
(float) $rep->getCommissionPercent(),
$rep !== null ? (float) $rep->getCommissionPercent() : 0.0,
$rep,
$doctorId,
null,
$secretaries,
);
}
/**
* نمایندهٔ واجد شرط برای پورسانت نوبت. گاردِ دامنه: پزشک باید متعلق به نماینده
* باشد و نوبت هم از دامنهٔ همان نماینده ثبت شده باشد.
*/
private function eligibleAppointmentRep(?int $doctorRepId, ?int $bookingRepId): ?Representation
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return null;
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return null;
return $this->resolveRep($doctorRepId);
}
/**
* پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent.
* گاردِ دامنه (مثل نوبت): مالک پزشک/کلینیک و نماینده‌ی دامنه‌ی خرید باید یکی باشند.
@@ -72,6 +93,7 @@ class CommissionService
$rep,
$doctorId,
$clinicId,
[],
);
}
@@ -82,13 +104,17 @@ class CommissionService
return ($rep !== null && $rep->isActive()) ? $rep : null;
}
/**
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
*/
private function settle(
Payment $payment,
string $source,
float $commissionPercent,
Representation $rep,
?Representation $rep,
?int $doctorId,
?int $clinicId,
array $secretaries,
): void {
// پرداخت دوبار پردازش نشود.
if ($this->breakdownRepo->existsForPayment($payment)) return;
@@ -107,18 +133,24 @@ class CommissionService
: 0;
$netAfterTax = $afterSms - $taxRials;
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات.
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare;
// مرحله ۳: سهم‌ها، همه از «خالصِ پس از مالیات» — نه از مبلغ کل و نه از
// باقی‌ماندهٔ سهم دیگری، تا ترتیب اجرا روی مبالغ اثر نگذارد.
[$commissionPercent, $secretaries] = $this->clipPercents($payment, $commissionPercent, $secretaries);
$repUser = $rep->getUser();
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$secretaryTotal = 0;
$secretaryRows = [];
foreach ($secretaries as $secretary) {
$share = (int) round($netAfterTax * $secretary['percent'] / 100);
if ($share <= 0) continue;
$secretaryTotal += $share;
$secretaryRows[] = $secretary + ['share' => $share];
}
if ($repShare > 0) {
$balance = $this->settlementRepo->getWalletBalance($repUser);
$tx = new WalletTransaction($repUser, $repShare, WalletTransaction::TYPE_CREDIT, $balance + $repShare);
$tx->setPayment($payment);
$tx->setDescription(sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
$this->walletRepo->save($tx, false);
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryTotal;
if ($rep !== null && $repShare > 0) {
$this->credit($rep->getUser(), $repShare, $payment, sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
}
$breakdown = new FinancialBreakdown(
@@ -133,12 +165,60 @@ class CommissionService
number_format($commissionPercent, 2, '.', ''),
$repShare,
$systemShare,
$rep->getId(),
$rep?->getId(),
$doctorId,
$clinicId,
);
$breakdown->setSecretaryShareRials($secretaryTotal);
$this->breakdownRepo->save($breakdown, false);
foreach ($secretaryRows as $row) {
$this->credit($row['user'], $row['share'], $payment, sprintf('سهم نوبت آنلاین %s', $payment->getOrderId()));
$this->earningRepo->save(
new SecretaryEarning($breakdown, $row['user'], $row['relation_uuid'], $row['percent'], $row['share']),
false,
);
}
$this->em->flush();
}
private function credit(User $user, int $amountRials, Payment $payment, string $description): void
{
$balance = $this->settlementRepo->getWalletBalance($user);
$tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_CREDIT, $balance + $amountRials);
$tx->setPayment($payment);
$tx->setDescription($description);
$this->walletRepo->save($tx, false);
}
/**
* سهم سیستم نباید منفی شود: اگر مجموع درصدها از ۱۰۰ بگذرد، به نسبت کلیپ می‌شود و
* هشدار ثبت می‌گردد (سکوت نمی‌کنیم — تنظیمِ اشتباه باید دیده شود).
*
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
* @return array{0: float, 1: list<array{user: User, percent: float, relation_uuid: string}>}
*/
private function clipPercents(Payment $payment, float $commissionPercent, array $secretaries): array
{
$total = $commissionPercent + array_sum(array_column($secretaries, 'percent'));
if ($total <= 100.0 || $total <= 0.0) {
return [$commissionPercent, $secretaries];
}
$this->logger->warning('Commission + secretary shares exceed 100% — clipping proportionally', [
'payment_uuid' => $payment->getUuid(),
'total_percent' => $total,
]);
$factor = 100.0 / $total;
$clipped = [];
foreach ($secretaries as $secretary) {
$secretary['percent'] *= $factor;
$clipped[] = $secretary;
}
return [$commissionPercent * $factor, $clipped];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Settlement\Service;
use App\Auth\Entity\User;
use App\Representation\Repository\RepresentationRepository;
use App\UserProfile\Repository\UserProfileRepository;
/**
* شبای تأییدشدهٔ یک کاربر، مستقل از نقشش.
*
* تسویه پیش‌تر فقط شبای نماینده را می‌شناخت، پس هر نقش دیگری (منشی، …) با وجود
* موجودی کیف پول نمی‌توانست برداشت کند. ترتیب: نماینده (سازگاری با داده‌ی موجود)،
* سپس پروفایل کاربر.
*/
class UserIbanResolver
{
public function __construct(
private readonly RepresentationRepository $representationRepo,
private readonly UserProfileRepository $profileRepo,
) {}
/** @return array<string, mixed>|null */
public function findVerifiedIban(User $user, string $ibanId): ?array
{
return $this->representationRepo->findByUser($user)?->findVerifiedIban($ibanId)
?? $this->profileRepo->findByUser($user)?->findVerifiedIban($ibanId);
}
/** @return array<int, array<string, mixed>> همهٔ شباهای کاربر (تأییدشده و نشده) */
public function ibansOf(User $user): array
{
$representationIbans = $this->representationRepo->findByUser($user)?->getIbans() ?? [];
return $representationIbans !== []
? $representationIbans
: ($this->profileRepo->findByUser($user)?->getIbans() ?? []);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Shared\Entity;
use Symfony\Component\Uid\Uuid;
/**
* شماره‌های شبای یک موجودیت (نماینده، پروفایل کاربر، …) روی ستون JSON `bank_account`.
* حداکثر دو شبا؛ `verified` فقط از سمت ادمین ست می‌شود و تسویه تنها با شبای تأییدشده
* انجام می‌گیرد. استفاده‌کننده باید ستون `bank_account` و متد `touch()` را داشته باشد.
*/
trait HasIbansTrait
{
/** @return array<int, array<string, mixed>> */
public function getIbans(): array
{
return $this->bankAccount ?? [];
}
/**
* @param array{iban: string, bank_name?: ?string, owner_name?: ?string, verified?: bool} $iban
* @throws \DomainException `iban_limit` وقتی از سقف دو شبا بگذرد
*/
public function addIban(array $iban): static
{
$ibans = $this->getIbans();
if (count($ibans) >= 2) {
throw new \DomainException('iban_limit');
}
$ibans[] = [
'id' => Uuid::v4()->toRfc4122(),
'iban' => $iban['iban'],
'bank_name' => $iban['bank_name'] ?? null,
'owner_name' => $iban['owner_name'] ?? null,
'verified' => $iban['verified'] ?? false,
'created_at' => time(),
];
$this->bankAccount = $ibans;
$this->touch();
return $this;
}
public function removeIban(string $id): static
{
$this->bankAccount = array_values(array_filter(
$this->getIbans(),
static fn(array $i) => ($i['id'] ?? null) !== $id,
));
$this->touch();
return $this;
}
/** @return array<string, mixed>|null یک شبای تأییدشده با این id */
public function findVerifiedIban(string $id): ?array
{
foreach ($this->getIbans() as $iban) {
if (($iban['id'] ?? null) === $id && ($iban['verified'] ?? false)) {
return $iban;
}
}
return null;
}
}
+14
View File
@@ -3,6 +3,7 @@
namespace App\UserProfile\Entity;
use App\Auth\Entity\User;
use App\Shared\Entity\HasIbansTrait;
use Doctrine\ORM\Mapping as ORM;
use App\UserProfile\Repository\UserProfileRepository;
use Symfony\Component\Uid\Uuid;
@@ -13,6 +14,8 @@ use Symfony\Component\Uid\Uuid;
#[ORM\UniqueConstraint(name: 'uniq_profiles_national_code', columns: ['national_code'])]
class UserProfile
{
use HasIbansTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -103,6 +106,14 @@ class UserProfile
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $avatar = null;
/**
* ۰ تا ۲ شماره شبای کاربر برای تسویه — همان ساختار نماینده
* ({@see \App\Shared\Entity\HasIbansTrait}). محلِ درستِ شبا کاربر است نه نقش،
* چون یک کاربر می‌تواند چند رابطهٔ منشی داشته باشد.
*/
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -177,6 +188,9 @@ class UserProfile
public function setDescription(?string $v): self { $this->description = $v; $this->touch(); return $this; }
public function setAvatar(?string $v): self { $this->avatar = $v; $this->touch(); return $this; }
public function getBankAccount(): ?array { return $this->bankAccount; }
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
@@ -0,0 +1,202 @@
<?php
namespace App\Tests\Secretary;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Config\Repository\SiteConfigRepository;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Settlement\Repository\SettlementRepository;
use App\Settlement\Service\CommissionService;
use App\Tests\ApiTestCase;
/**
* سهم منشی از نوبت آنلاین: درصدِ رابطهٔ منشی روی «خالصِ پس از مالیات» — همان مبنایی
* که پورسانت نماینده از آن گرفته می‌شود — و مستقل از وجود نماینده.
*/
class SecretaryOnlineShareTest extends ApiTestCase
{
private const GROSS = 10_000_000;
protected function setUp(): void
{
parent::setUp();
$config = static::getContainer()->get(SiteConfigRepository::class);
// کسورات را قطعی می‌کنیم تا انتظارِ عددی تست پایدار بماند.
$config->set('sms_panel_fee_rials', '0');
$config->set('tax_enabled', '0');
$config->set('tax_percent', '0');
}
private function makeDoctor(): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر تست سهم منشی');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeSecretary(Doctor $doctor, float $percent, bool $enabled = true, ?Clinic $clinic = null): DoctorSecretary
{
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary(
$doctor,
$user,
$clinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR,
$clinic,
);
$relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent);
$this->em->persist($relation);
$this->em->flush();
return $relation;
}
private function makeOnlinePayment(Doctor $doctor, ?Clinic $clinic = null): Payment
{
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setClinic($clinic);
$this->em->persist($appointment);
$payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');
$payment->setAppointment($appointment);
$this->em->persist($payment);
$this->em->flush();
return $payment;
}
private function commission(): CommissionService
{
return static::getContainer()->get(CommissionService::class);
}
private function earnings(): SecretaryEarningRepository
{
return static::getContainer()->get(SecretaryEarningRepository::class);
}
public function testSecretaryEarnsItsPercentWithoutAnyRepresentation(): void
{
$doctor = $this->makeDoctor();
$relation = $this->makeSecretary($doctor, 5.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
$breakdown = static::getContainer()->get(FinancialBreakdownRepository::class)->findOneBy(['payment' => $payment]);
self::assertNotNull($breakdown, 'نوبتِ بدون نماینده هم باید تفکیک مالی بسازد');
self::assertSame(500_000, $breakdown->getSecretaryShareRials()); // ۵٪ از ۱۰,۰۰۰,۰۰۰
self::assertSame(self::GROSS - 500_000, $breakdown->toArray()['system_share_rials']);
self::assertSame(500_000, $this->earnings()->sumFor($relation->getSecretary()));
self::assertSame(
500_000,
static::getContainer()->get(SettlementRepository::class)->getWalletBalance($relation->getSecretary()),
);
}
public function testDisabledOrZeroPercentEarnsNothing(): void
{
$doctor = $this->makeDoctor();
$off = $this->makeSecretary($doctor, 5.0, enabled: false);
$zero = $this->makeSecretary($doctor, 0.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
self::assertFalse(static::getContainer()->get(FinancialBreakdownRepository::class)->existsForPayment($payment));
self::assertSame(0, $this->earnings()->sumFor($off->getSecretary()));
self::assertSame(0, $this->earnings()->sumFor($zero->getSecretary()));
}
public function testEachSecretaryGetsItsOwnPercent(): void
{
$doctor = $this->makeDoctor();
$first = $this->makeSecretary($doctor, 5.0);
$second = $this->makeSecretary($doctor, 10.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
self::assertSame(500_000, $this->earnings()->sumFor($first->getSecretary()));
self::assertSame(1_000_000, $this->earnings()->sumFor($second->getSecretary()));
}
public function testPaymentIsNeverSplitTwice(): void
{
$doctor = $this->makeDoctor();
$relation = $this->makeSecretary($doctor, 5.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
self::assertSame(500_000, $this->earnings()->sumFor($relation->getSecretary()));
self::assertSame(1, $this->earnings()->countFor($relation->getSecretary()));
}
/** مجموع درصدها > ۱۰۰ → کلیپ می‌شود و سهم سیستم منفی نمی‌شود. */
public function testSharesAreClippedSoTheSystemShareStaysPositive(): void
{
$doctor = $this->makeDoctor();
$a = $this->makeSecretary($doctor, 80.0);
$b = $this->makeSecretary($doctor, 60.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
$breakdown = static::getContainer()->get(FinancialBreakdownRepository::class)->findOneBy(['payment' => $payment]);
self::assertGreaterThanOrEqual(0, $breakdown->toArray()['system_share_rials']);
self::assertSame(self::GROSS, $breakdown->getSecretaryShareRials() + $breakdown->toArray()['system_share_rials']);
self::assertGreaterThan(0, $this->earnings()->sumFor($a->getSecretary()));
self::assertGreaterThan(0, $this->earnings()->sumFor($b->getSecretary()));
}
public function testClinicAppointmentCreditsTheClinicSecretaries(): void
{
$doctor = $this->makeDoctor();
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست سهم منشی');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
$clinicSecretary = $this->makeSecretary($doctor, 7.0, clinic: $clinic);
$officeSecretary = $this->makeSecretary($doctor, 5.0); // مطب شخصی — نباید سهم بگیرد
$payment = $this->makeOnlinePayment($doctor, $clinic);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
self::assertSame(700_000, $this->earnings()->sumFor($clinicSecretary->getSecretary()));
self::assertSame(0, $this->earnings()->sumFor($officeSecretary->getSecretary()));
}
/** سهم از «خالص» گرفته می‌شود: با مالیات و هزینهٔ پیامک، مبنا کمتر است. */
public function testShareIsCalculatedOnTheNetAmount(): void
{
$config = static::getContainer()->get(SiteConfigRepository::class);
$config->set('sms_panel_fee_rials', '1000000');
$config->set('tax_enabled', '1');
$config->set('tax_percent', '10');
$doctor = $this->makeDoctor();
$relation = $this->makeSecretary($doctor, 10.0);
$payment = $this->makeOnlinePayment($doctor);
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
// ۱۰,۰۰۰,۰۰۰ − ۱,۰۰۰,۰۰۰ = ۹,۰۰۰,۰۰۰ ؛ مالیات = 9,000,000×10/110 = 818,182
// خالص = 8,181,818 ؛ سهم ۱۰٪ = 818,182
self::assertSame(818_182, $this->earnings()->sumFor($relation->getSecretary()));
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace App\Tests\Secretary;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* اندپوینت‌های مدیریت سهم منشی (ادمین) و گزارش/شبا (پنل منشی).
*/
class SecretaryShareApiTest extends ApiTestCase
{
/** @return array{0: DoctorSecretary, 1: User} */
private function makeRelation(): array
{
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر تست API سهم');
$this->em->persist($doctor);
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary($doctor, $secretaryUser);
$this->em->persist($relation);
$this->em->flush();
return [$relation, $secretaryUser];
}
// ── ادمین ────────────────────────────────────────────────────────────────
public function testAdminSeesTheRelationDetailWithEarnings(): void
{
[$relation] = $this->makeRelation();
$admin = $this->createUser(['ROLE_ADMIN']);
$body = $this->authJson('GET', '/api/v1/admin/secretary/' . $relation->getUuid(), $admin);
self::assertSame(200, $this->responseCode());
$row = $body['data']['data'];
self::assertFalse($row['online_share_enabled']);
// JSON عددِ گِرد را بدون ممیز می‌دهد، پس مقایسه‌ی نوع‌آزاد.
self::assertEquals(0, $row['online_share_percent']);
self::assertSame(0, $row['earnings']['total_rials']);
}
public function testAdminEnablesTheShareWithAPercent(): void
{
[$relation] = $this->makeRelation();
$admin = $this->createUser(['ROLE_ADMIN']);
$body = $this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
'enabled' => true,
'percent' => 5,
]);
self::assertSame(200, $this->responseCode());
self::assertTrue($body['data']['data']['online_share_enabled']);
self::assertEquals(5, $body['data']['data']['online_share_percent']);
}
public function testPercentAbove100IsRejected(): void
{
[$relation] = $this->makeRelation();
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
'enabled' => true, 'percent' => 101,
]);
self::assertSame(422, $this->responseCode());
}
public function testEnablingWithoutAPercentIsRejected(): void
{
[$relation] = $this->makeRelation();
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
'enabled' => true, 'percent' => 0,
]);
self::assertSame(422, $this->responseCode());
}
public function testNonAdminIsForbidden(): void
{
[$relation, $secretaryUser] = $this->makeRelation();
$this->authJson('GET', '/api/v1/admin/secretary/' . $relation->getUuid(), $secretaryUser);
self::assertSame(403, $this->responseCode());
}
public function testUnknownRelationIsNotFound(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('GET', '/api/v1/admin/secretary/00000000-0000-4000-8000-000000000000', $admin);
self::assertSame(404, $this->responseCode());
}
// ── پنل منشی ─────────────────────────────────────────────────────────────
public function testSummaryReportsDisabledWhenNoRelationHasAShare(): void
{
[, $secretaryUser] = $this->makeRelation();
$body = $this->authJson('GET', '/api/v1/secretary/earnings/summary', $secretaryUser);
self::assertSame(200, $this->responseCode());
self::assertFalse($body['data']['data']['enabled']);
self::assertSame(0, $body['data']['data']['total_rials']);
}
public function testSummaryReportsEnabledWithThePercentOnceAdminTurnsItOn(): void
{
[$relation, $secretaryUser] = $this->makeRelation();
$relation->setOnlineShareEnabled(true)->setOnlineSharePercent(7.5);
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/secretary/earnings/summary', $secretaryUser);
self::assertSame(200, $this->responseCode());
self::assertTrue($body['data']['data']['enabled']);
self::assertSame(7.5, $body['data']['data']['share_percent']);
}
public function testReportIsEmptyButSucceedsWithoutEarnings(): void
{
[, $secretaryUser] = $this->makeRelation();
$body = $this->authJson('GET', '/api/v1/secretary/earnings/report', $secretaryUser);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']);
self::assertSame(0, $body['meta']['totalRecords']);
}
public function testSecretaryManagesItsIbans(): void
{
[, $secretaryUser] = $this->makeRelation();
$added = $this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, [
'iban' => 'IR' . str_pad((string) random_int(0, 999_999), 24, '0', STR_PAD_LEFT),
'bank_name' => 'ملی',
]);
self::assertSame(201, $this->responseCode());
$ibans = $added['data']['data']['bank_account'];
self::assertCount(1, $ibans);
// شبای تازه تأییدنشده است؛ تسویه با آن مجاز نیست.
self::assertFalse($ibans[0]['verified']);
$removed = $this->authJson('DELETE', '/api/v1/secretary/iban/' . $ibans[0]['id'], $secretaryUser);
self::assertSame(200, $this->responseCode());
self::assertSame([], $removed['data']['data']['bank_account']);
}
public function testInvalidIbanIsRejected(): void
{
[, $secretaryUser] = $this->makeRelation();
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => '12345']);
self::assertSame(422, $this->responseCode());
}
public function testThirdIbanIsRejected(): void
{
[, $secretaryUser] = $this->makeRelation();
$iban = fn() => 'IR' . str_pad((string) random_int(0, 999_999_999), 24, '0', STR_PAD_LEFT);
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
self::assertSame(422, $this->responseCode());
}
public function testSettlementAcceptsAVerifiedSecretaryIban(): void
{
[, $secretaryUser] = $this->makeRelation();
$added = $this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, [
'iban' => 'IR' . str_pad((string) random_int(0, 999_999), 24, '0', STR_PAD_LEFT),
]);
$ibanId = $added['data']['data']['bank_account'][0]['id'];
// تأیید شبا کارِ ادمین است؛ اینجا مستقیم روی پروفایل ست می‌کنیم.
$profile = static::getContainer()->get(\App\UserProfile\Repository\UserProfileRepository::class)
->findByUser($secretaryUser);
$ibans = $profile->getIbans();
$ibans[0]['verified'] = true;
$profile->setBankAccount($ibans);
$this->em->flush();
// بدون موجودی: خطای موجودی می‌گیرد، نه خطای شبا — یعنی شبا پذیرفته شده است.
$body = $this->authJson('POST', '/api/v1/settlement', $secretaryUser, [
'amount_rials' => 100_000,
'iban_id' => $ibanId,
]);
self::assertSame(422, $this->responseCode());
self::assertSame('موجودی کافی نیست', $body['errors'][0]['message']);
}
}