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:
@@ -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).
|
||||
@@ -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>} />
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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` لاگ میشود تا سهم سیستم منفی نشود.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() ?? []);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user