# سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی) ## زمینه نوبتی که از سایت عمومی به‌صورت **آنلاین** رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: `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` به‌جای `