From 8d2b0d908ab65363725e9fc0a4b5a6cc9e008428 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 25 Jul 2026 18:34:18 +0330 Subject: [PATCH] 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. --- .../secretary-online-appointment-share.md | 301 ++++++++++++++++++ assets/admin/App.tsx | 6 + assets/admin/components/layout/Sidebar.tsx | 28 +- assets/admin/hooks/useSecretaryEarnings.ts | 30 ++ assets/admin/pages/SecretariesPage.tsx | 13 +- .../admin/pages/SecretaryDetailPage.test.tsx | 93 ++++++ assets/admin/pages/SecretaryDetailPage.tsx | 144 +++++++++ .../pages/SecretaryEarningsPage.test.tsx | 63 ++++ assets/admin/pages/SecretaryEarningsPage.tsx | 124 ++++++++ .../admin/pages/SecretarySettlementPage.tsx | 219 +++++++++++++ assets/admin/types/index.ts | 10 + docs/api/admin.md | 82 ++++- docs/api/secretary.md | 173 ++++++++++ docs/api/settlement.md | 24 +- migrations/Version20260725143211.php | 30 ++ migrations/Version20260725143539.php | 30 ++ migrations/Version20260725144018.php | 36 +++ src/Admin/Controller/AdminApiController.php | 74 +++++ src/Representation/Entity/Representation.php | 50 +-- .../Controller/SecretaryController.php | 166 ++++++++++ src/Secretary/Entity/DoctorSecretary.php | 22 ++ src/Secretary/Entity/SecretaryEarning.php | 86 +++++ .../Repository/DoctorSecretaryRepository.php | 30 ++ .../Repository/SecretaryEarningRepository.php | 102 ++++++ .../Service/SecretaryShareResolver.php | 39 +++ .../Controller/SettlementController.php | 6 +- src/Settlement/Entity/FinancialBreakdown.php | 10 + src/Settlement/Service/CommissionService.php | 120 +++++-- src/Settlement/Service/UserIbanResolver.php | 39 +++ src/Shared/Entity/HasIbansTrait.php | 67 ++++ src/UserProfile/Entity/UserProfile.php | 14 + tests/Secretary/SecretaryOnlineShareTest.php | 202 ++++++++++++ tests/Secretary/SecretaryShareApiTest.php | 207 ++++++++++++ 33 files changed, 2564 insertions(+), 76 deletions(-) create mode 100644 .claude/prompt/secretary-online-appointment-share.md create mode 100644 assets/admin/hooks/useSecretaryEarnings.ts create mode 100644 assets/admin/pages/SecretaryDetailPage.test.tsx create mode 100644 assets/admin/pages/SecretaryDetailPage.tsx create mode 100644 assets/admin/pages/SecretaryEarningsPage.test.tsx create mode 100644 assets/admin/pages/SecretaryEarningsPage.tsx create mode 100644 assets/admin/pages/SecretarySettlementPage.tsx create mode 100644 migrations/Version20260725143211.php create mode 100644 migrations/Version20260725143539.php create mode 100644 migrations/Version20260725144018.php create mode 100644 src/Secretary/Entity/SecretaryEarning.php create mode 100644 src/Secretary/Repository/SecretaryEarningRepository.php create mode 100644 src/Secretary/Service/SecretaryShareResolver.php create mode 100644 src/Settlement/Service/UserIbanResolver.php create mode 100644 src/Shared/Entity/HasIbansTrait.php create mode 100644 tests/Secretary/SecretaryOnlineShareTest.php create mode 100644 tests/Secretary/SecretaryShareApiTest.php diff --git a/.claude/prompt/secretary-online-appointment-share.md b/.claude/prompt/secretary-online-appointment-share.md new file mode 100644 index 00000000..bb3f2ee7 --- /dev/null +++ b/.claude/prompt/secretary-online-appointment-share.md @@ -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` با توضیح فارسی (`سهم نوبت آنلاین `) ثبت شود — همان الگوی نماینده. +- `systemShare` هرگز نباید منفی شود: مجموع `repPercent + Σ secretaryPercent` را در `settle()` به ۱۰۰ کلیپ کن و اگر کلیپ شد یک `logger->warning` با `payment_uuid` بنویس (سکوت نکن). + +سرویس جدید `src/Secretary/Service/SecretaryShareResolver.php`: + +```php +/** + * منشی‌هایی که از این نوبت آنلاین سهم می‌برند: رابطهٔ فعالِ همان پزشک (یا کلینیکِ نوبت) + * با online_share_enabled و درصد > ۰. + * + * @return list + */ +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` به‌جای ` setEnabled(e.target.checked)} + /> + + + محاسبه درآمد از نوبت‌های آنلاین فعال باشد + + +
+ + setPercent(digitsOnly(e.target.value, 3))} + placeholder="مثلاً: ۵" + /> + {Number(percent) > 100 &&

درصد نمی‌تواند بیشتر از ۱۰۰ باشد

} + {enabled && Number(percent) <= 0 &&

برای فعال‌سازی، درصد باید بیشتر از صفر باشد

} +
+ + + + +
+

خلاصه درآمد

+ + + +
+ + + ); +} diff --git a/assets/admin/pages/SecretaryEarningsPage.test.tsx b/assets/admin/pages/SecretaryEarningsPage.test.tsx new file mode 100644 index 00000000..9f289a07 --- /dev/null +++ b/assets/admin/pages/SecretaryEarningsPage.test.tsx @@ -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; + +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, 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(); + + 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(); + + await waitFor(() => expect(screen.getByText(/برای شما فعال نیست/)).toBeInTheDocument()); + expect(screen.queryByText('امروز')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/SecretaryEarningsPage.tsx b/assets/admin/pages/SecretaryEarningsPage.tsx new file mode 100644 index 00000000..b283d279 --- /dev/null +++ b/assets/admin/pages/SecretaryEarningsPage.tsx @@ -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>({ + 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>(`/api/v1/secretary/earnings/report?${params}`); + }, + }); + + const rows = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + const columns: Column[] = [ + { 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) => ( + {formatRial(r.share_rials)} + ) }, + ]; + + return ( +
+ + + {summary && !summary.enabled ? ( +
+ محاسبه درآمد از نوبت‌های آنلاین برای شما فعال نیست. برای فعال‌سازی با مدیر سیستم تماس بگیرید. +
+ ) : ( + <> +
+ + + + +
+ +
+
+
+
+ + { setFrom(v); setPage(1); }} placeholder="انتخاب" /> +
+
+ + { setTo(v); setPage(1); }} placeholder="انتخاب" /> +
+
+ {formatNumber(summary?.appointments_count ?? 0)} نوبت +
+
+
+ + + columns={columns} + data={rows} + loading={isLoading} + emptyMessage="هنوز درآمدی از نوبت آنلاین ثبت نشده است" + /> + +
+ + )} +
+ ); +} diff --git a/assets/admin/pages/SecretarySettlementPage.tsx b/assets/admin/pages/SecretarySettlementPage.tsx new file mode 100644 index 00000000..804b95b1 --- /dev/null +++ b/assets/admin/pages/SecretarySettlementPage.tsx @@ -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 = { + pending: 'در انتظار بررسی', + approved: 'تأییدشده', + rejected: 'رد شده', + paid: 'پرداخت‌شده', +}; + +/** unwrap پاسخ‌های احتمالاً تودرتوی `success(['data' => …])`. */ +const unwrap = (res: ApiResponse | 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>({ + queryKey: ['wallet-balance'], + queryFn: () => api.get('/api/v1/wallet/balance'), + }); + const balance = unwrap(balanceQ.data)?.balance_rials ?? 0; + + const meQ = useQuery>({ + 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>({ + 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[] = [ + { 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) => {r.bank_account?.iban ?? '—'} }, + { key: 'status', header: 'وضعیت', render: (r) => STATUS_LABEL[r.status] ?? r.status }, + ]; + + return ( +
+ + +
+ +
+ +
+

شماره شبا

+ + {ibans.length === 0 ? ( +

هنوز شبایی ثبت نکرده‌اید.

+ ) : ( +
+ {ibans.map((b) => ( +
+ {b.iban} + {b.bank_name && {b.bank_name}} + + {b.verified ? 'تأییدشده' : 'در انتظار تأیید'} + + +
+ ))} +
+ )} + + {ibans.length < 2 && ( +
+
+ + setNewIban(e.target.value)} + /> +
+
+ + setBankName(e.target.value)} /> +
+ +
+ )} +
+ +
+

درخواست تسویه

+ + {verifiedIbans.length === 0 ? ( +

+ برای ثبت درخواست تسویه، باید حداقل یک شماره شبای تأییدشده داشته باشید. +

+ ) : ( +
+
+ + +
+
+ + ({ value: b.id, label: `${b.iban}${b.bank_name ? ` — ${b.bank_name}` : ''}` }))} + value={ibanId || null} + onChange={(v) => setIbanId(v ? String(v) : '')} + placeholder="انتخاب شبا" + /> +
+ +
+ )} +
+ +
+ + columns={columns} + data={settlements} + loading={listQ.isLoading} + emptyMessage="درخواست تسویه‌ای ثبت نشده است" + /> +
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index e3c9faa7..c2b3f3e3 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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 { diff --git a/docs/api/admin.md b/docs/api/admin.md index fa64d78b..1a512f77 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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). --- diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 0453fa19..3b422d84 100644 --- a/docs/api/secretary.md +++ b/docs/api/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)). diff --git a/docs/api/settlement.md b/docs/api/settlement.md index e4197db2..b6d20d3d 100644 --- a/docs/api/settlement.md +++ b/docs/api/settlement.md @@ -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` لاگ می‌شود تا سهم سیستم منفی نشود. --- diff --git a/migrations/Version20260725143211.php b/migrations/Version20260725143211.php new file mode 100644 index 00000000..e4034707 --- /dev/null +++ b/migrations/Version20260725143211.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/migrations/Version20260725143539.php b/migrations/Version20260725143539.php new file mode 100644 index 00000000..9dd04fcd --- /dev/null +++ b/migrations/Version20260725143539.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE profiles ADD bank_account JSON DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE profiles DROP bank_account'); + } +} diff --git a/migrations/Version20260725144018.php b/migrations/Version20260725144018.php new file mode 100644 index 00000000..c47a87c6 --- /dev/null +++ b/migrations/Version20260725144018.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index f3f15bf9..d7609add 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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( diff --git a/src/Representation/Entity/Representation.php b/src/Representation/Entity/Representation.php index 684f0257..60b55689 100644 --- a/src/Representation/Entity/Representation.php +++ b/src/Representation/Entity/Representation.php @@ -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> */ - 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|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 diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index 9c67e78c..25d69f62 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -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 { diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index a8371be4..2c6e1f35 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -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(), diff --git a/src/Secretary/Entity/SecretaryEarning.php b/src/Secretary/Entity/SecretaryEarning.php new file mode 100644 index 00000000..149dd287 --- /dev/null +++ b/src/Secretary/Entity/SecretaryEarning.php @@ -0,0 +1,86 @@ +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, + ]; + } +} diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index 68cbba97..b8753e5a 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -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); diff --git a/src/Secretary/Repository/SecretaryEarningRepository.php b/src/Secretary/Repository/SecretaryEarningRepository.php new file mode 100644 index 00000000..3e883288 --- /dev/null +++ b/src/Secretary/Repository/SecretaryEarningRepository.php @@ -0,0 +1,102 @@ +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>, 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]; + } +} diff --git a/src/Secretary/Service/SecretaryShareResolver.php b/src/Secretary/Service/SecretaryShareResolver.php new file mode 100644 index 00000000..6ae0d677 --- /dev/null +++ b/src/Secretary/Service/SecretaryShareResolver.php @@ -0,0 +1,39 @@ + + */ + 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)); + } +} diff --git a/src/Settlement/Controller/SettlementController.php b/src/Settlement/Controller/SettlementController.php index 1400267f..bf009a92 100644 --- a/src/Settlement/Controller/SettlementController.php +++ b/src/Settlement/Controller/SettlementController.php @@ -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'); } diff --git a/src/Settlement/Entity/FinancialBreakdown.php b/src/Settlement/Entity/FinancialBreakdown.php index b0d30b05..23dafea3 100644 --- a/src/Settlement/Entity/FinancialBreakdown.php +++ b/src/Settlement/Entity/FinancialBreakdown.php @@ -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, diff --git a/src/Settlement/Service/CommissionService.php b/src/Settlement/Service/CommissionService.php index 34073fbe..37d00ce2 100644 --- a/src/Settlement/Service/CommissionService.php +++ b/src/Settlement/Service/CommissionService.php @@ -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 $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 $secretaries + * @return array{0: float, 1: list} + */ + 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]; + } } diff --git a/src/Settlement/Service/UserIbanResolver.php b/src/Settlement/Service/UserIbanResolver.php new file mode 100644 index 00000000..94b16ab7 --- /dev/null +++ b/src/Settlement/Service/UserIbanResolver.php @@ -0,0 +1,39 @@ +|null */ + public function findVerifiedIban(User $user, string $ibanId): ?array + { + return $this->representationRepo->findByUser($user)?->findVerifiedIban($ibanId) + ?? $this->profileRepo->findByUser($user)?->findVerifiedIban($ibanId); + } + + /** @return array> همهٔ شباهای کاربر (تأییدشده و نشده) */ + public function ibansOf(User $user): array + { + $representationIbans = $this->representationRepo->findByUser($user)?->getIbans() ?? []; + + return $representationIbans !== [] + ? $representationIbans + : ($this->profileRepo->findByUser($user)?->getIbans() ?? []); + } +} diff --git a/src/Shared/Entity/HasIbansTrait.php b/src/Shared/Entity/HasIbansTrait.php new file mode 100644 index 00000000..6ef1925b --- /dev/null +++ b/src/Shared/Entity/HasIbansTrait.php @@ -0,0 +1,67 @@ +> */ + 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|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; + } +} diff --git a/src/UserProfile/Entity/UserProfile.php b/src/UserProfile/Entity/UserProfile.php index 518f56b7..b323925b 100644 --- a/src/UserProfile/Entity/UserProfile.php +++ b/src/UserProfile/Entity/UserProfile.php @@ -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 diff --git a/tests/Secretary/SecretaryOnlineShareTest.php b/tests/Secretary/SecretaryOnlineShareTest.php new file mode 100644 index 00000000..4a8275bb --- /dev/null +++ b/tests/Secretary/SecretaryOnlineShareTest.php @@ -0,0 +1,202 @@ +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())); + } +} diff --git a/tests/Secretary/SecretaryShareApiTest.php b/tests/Secretary/SecretaryShareApiTest.php new file mode 100644 index 00000000..c3b11e9d --- /dev/null +++ b/tests/Secretary/SecretaryShareApiTest.php @@ -0,0 +1,207 @@ +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']); + } +}