- 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.
23 KiB
سهم درآمد منشی از نوبتهای آنلاین (فعالسازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)
زمینه
نوبتی که از سایت عمومی بهصورت آنلاین رزرو و با پرداخت موفق قطعی میشود، همین حالا از یک موتور تقسیم مالی عبور میکند: 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
$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;
و گاردِ ورودی — بدون نماینده، کل متد زودتر برمیگردد و هیچ تفکیکی ثبت نمیشود:
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
$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
#[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:
/** محاسبهٔ سهم منشی از نوبتهای آنلاینِ همین پزشک/کلینیک فعال است؟ */
#[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:
/** ۰ تا ۲ شماره شبا؛ هر آیتم: { 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:
/** شبای تأییدشدهٔ یک کاربر، از هر منبعی که دارد: نماینده، وگرنه پروفایل کاربر. */
public function findVerifiedIban(User $user, string $ibanId): ?array
و SettlementController::request() بهجای representationRepo->findByUser(...) از همین resolver استفاده کند. بدونِ این تغییر، منشی نمیتواند تسویه بزند.
۳. سهم منشی در موتور تقسیم مالی
src/Settlement/Entity/FinancialBreakdown.php: دو ستون تازه + migration
#[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 میکند؛ سهم منشی نباید به وجود نماینده گره بخورد. بازآرایی پیشنهادی:
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(بدون تغییر در دو مرحلهٔ اول):
// سهم منشی — مثل نماینده از «خالصِ پس از مالیات»، نه از مبلغ کل.
$secretaryShare = (int) round($netAfterTax * $secretaryPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryShare;
- برای هر منشیِ واجد شرط یک
WalletTransactionاز نوعTYPE_CREDITبا توضیح فارسی (سهم نوبت آنلاین <orderId>) ثبت شود — همان الگوی نماینده. systemShareهرگز نباید منفی شود: مجموعrepPercent + Σ secretaryPercentرا درsettle()به ۱۰۰ کلیپ کن و اگر کلیپ شد یکlogger->warningباpayment_uuidبنویس (سکوت نکن).
سرویس جدید src/Secretary/Service/SecretaryShareResolver.php:
/**
* منشیهایی که از این نوبت آنلاین سهم میبرند: رابطهٔ فعالِ همان پزشک (یا کلینیکِ نوبت)
* با online_share_enabled و درصد > ۰.
*
* @return list<array{user: User, percent: float, relation_uuid: string}>
*/
public function for(Payment $payment): array
- مبنای انتساب:
payment->getAppointment()→ اگرappointment->getClinic()پر بود، رابطههایownerType='clinic'همان کلینیک؛ وگرنه رابطههای همان پزشک. - فقط
active = trueوonline_share_enabled = true. - «آنلاین» یعنی همین مسیر:
CommissionService::processAppointmentتنها ازPaymentManager(پرداخت موفق درگاه) صدا زده میشود؛ نوبتهای ثبتشده در پنل از این مسیر عبور نمیکنند و سهمی نمیسازند. این را در docblock بنویس.
۴. اندپوینتهای ادمین برای مدیریت سهم
در src/Admin/Controller/AdminApiController.php (کنار GET /api/v1/admin/secretaries، همان #[IsGranted('ROLE_ADMIN')]):
#[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 (گِیت: کاربر باید رابطهٔ فعال منشی داشته باشد):
#[Route('/api/v1/secretary/earnings/summary', methods: ['GET'])]
public function earningsSummary(#[CurrentUser] User $user): JsonResponse
{
// { enabled: bool, share_percent: float, today_rials, this_month_rials,
// total_rials, wallet_balance_rials, appointments_count }
}
#[Route('/api/v1/secretary/earnings/report', methods: ['GET'])]
public function earningsReport(Request $request, #[CurrentUser] User $user): JsonResponse
{
// paginated؛ query: page, limit, from, to (Unix)
// هر ردیف: { uuid, appointment_uuid, doctor_name, created_at,
// gross_rials, sms_fee_rials, tax_rials, net_after_tax_rials,
// share_percent, share_rials }
}
- الگوی کوئری را از
RepresentationActionController::financeReport()(خط ۷۸۸) بگیر: DQL رویFinancialBreakdownباgetArrayResult()، فیلترb.secretaryUserId = :userId،paginated()برای پاسخ. enabled: falseوقتی هیچ رابطهٔ فعالِ دارای سهم ندارد؛ در این حالت گزارش خالی برگردد (نه ۴۰۳) تا فرانت بتواند پیام «این قابلیت برای شما فعال نیست» نشان دهد.- شبا: اندپوینتهای
POST /api/v1/secretary/ibanوDELETE /api/v1/secretary/iban/{id}با همان قواعد نماینده (سقف ۲،verifiedفقط توسط ادمین) رویUserProfileذخیره شوند؛GET /api/v1/secretary/meهمbank_accountرا برگرداند. - کیف پول و تسویه اندپوینت تازه لازم ندارند:
/api/v1/wallet/balance,/api/v1/wallet/transactions,POST /api/v1/settlementکاربر-محورند و با اصلاح گام ۲ برای منشی کار میکنند.
۶. پنل ادمین — صفحهٔ جزئیات منشی
- route جدید در
assets/admin/App.tsxکنار خط ۲۱۱:secretaries/:uuid→SecretaryDetailPageباRoleRoute roles={['admin']}. assets/admin/pages/SecretaryDetailPage.tsx(جدید) با الگویRepresentationDetailPage.tsx—PageHeader+ کارتهای موجود، بدون طراحی تازه:- کارت «اطلاعات منشی» (نام، موبایل، پزشک/کلینیک، وضعیت، مجوزها)
- کارت «سهم درآمد نوبتهای آنلاین»: سوییچ فعال/غیرفعال + ورودی درصد (
digitsOnly(value, 3)) + دکمهٔ ذخیره →PUT /api/v1/admin/secretary/{uuid}/online-share؛ پس از موفقیتqueryKeyهم لیست و هم جزئیات invalidate شود - کارت خلاصهٔ درآمد (
earningsاز پاسخ جزئیات) باformatRial
- در
assets/admin/pages/SecretariesPage.tsx: ستون «سهم آنلاین» (—وقتی غیرفعال) + کلیک ردیف/اکشن به صفحهٔ جزئیات.
۷. پنل منشی — درآمد و شبا
assets/admin/pages/SecretaryEarningsPage.tsx(جدید): کارتهای «امروز / این ماه / کل» +DataTableگزارش باPaginationو فیلتر تاریخ (PersianDateInput)؛ ستونها: تاریخ (formatDateشمسی)، پزشک، مبلغ نوبت، کسورات، خالص، درصد، سهم منشی.assets/admin/pages/SecretarySettlementPage.tsx(جدید): مثلRepresentationSettlementPage.tsx— موجودی کیف پول، مدیریت شبا (افزودن/حذف، حداکثر ۲، نمایشverified)، فرم درخواست تسویه با انتخاب شبای تأییدشده، و لیست درخواستها.- routeها با
RoleRoute roles={['secretary']}؛ آیتم منو فقط وقتیenabledدرearnings/summaryدرست است (منوی نقش منشی همان جایی که بقیهٔ آیتمهای منشی تعریف شدهاند). - هیچ محاسبهٔ مالی در فرانت تکرار نشود: همهٔ اعداد از سرور میآیند.
۸. مستندات و تست
docs/api/admin.md:GET /api/v1/admin/secretary/{uuid}،PUT …/online-share(بدنه، خطاها)، و فیلدهای تازه در لیست منشیها.docs/api/secretary.md:earnings/summary،earnings/report(با پارامترهای query و مثال JSON)، اندپوینتهای شبا، و اشاره به اینکه کیف پول/تسویه ازdocs/api/settlement.mdمیآید.docs/api/settlement.md: تسویه دیگر مخصوص نماینده نیست؛ شبا از نماینده یا پروفایل کاربر resolve میشود.- PHPUnit:
CommissionService: منشیِ فعال با ۵٪ →secretary_share_rials = round(netAfterTax × 5 / 100)وsystem_share = gross − sms − tax − rep − secretary؛ نوبت بدون نماینده ولی با منشیِ فعال → تفکیک ساخته شود (رگرسیونِ گاردِ فعلی)؛ منشیِ غیرفعال/درصد صفر → هیچ سهمی؛ پرداخت تکراری → دوباره پردازش نشود؛ مجموع درصدها > ۱۰۰ → کلیپ و لاگ،systemShare >= 0.SecretaryShareResolver: نوبت کلینیکی → منشیهای همان کلینیک؛ نوبت مطب → منشیهای همان پزشک؛ رابطهٔ غیرفعال حساب نشود.- Controller ادمین:
percent = 101→ ۴۲۲ ·enabled=true, percent=0→ ۴۲۲ · کاربر غیرادمین → ۴۰۳. SettlementController: منشی با شبای تأییدشده درUserProfileمیتواند تسویه بزند؛ شبای تأییدنشده → ۴۲۲.SecretaryController: منشیِ بدون سهم →enabled: falseو گزارش خالی با ۲۰۰.
- Vitest:
SecretaryDetailPage(ذخیرهٔ سوییچ+درصد، خطای درصد > ۱۰۰)،SecretaryEarningsPage(نمایش کارتها و ردیفها از پاسخ paginated). - اجرای واقعی:
ddev exec php bin/phpunit·ddev exec php vendor/bin/phpstan analyse·ddev exec npx tsc --noEmit --project tsconfig.json·ddev exec yarn dev·npx vitest run(⚠️ vitest داخل ddev اجرا نمیشود — روی host اجرا کن).
نکات مهم
- ترتیب کسورات تغییر نکند: پیامک → مالیات → سهمها. سهم منشی و پورسانت نماینده هر دو از
netAfterTaxگرفته میشوند؛ نه ازgrossو نه از باقیماندهٔ پس از سهم دیگری (وگرنه ترتیبِ اجرا روی مبلغ اثر میگذارد). - گرد کردن:
(int) round(...)مثل کد فعلی؛systemShareهمیشه از تفریق حساب شود تا مجموع سهمها باgrossبرابر بماند. - idempotency:
existsForPayment()باید قبل از هر اعتبارِ کیف پول چک شود؛ الآن داخلsettle()است و با اضافهشدن مسیر منشی باید درprocessAppointmentهم گارد شود. - فقط نوبت آنلاین: نوبتهای ثبتشده در پنل (
POST /api/v1/my/appointmentو مودال «قطعی کردن نوبت») ازPaymentManagerعبور نمیکنند و سهم نمیسازند. اگر بعداً لازم شد، مسیر جداگانهای باشد نه تغییر این یکی. - چند منشی: یک پزشک/کلینیک میتواند چند منشیِ دارای سهم داشته باشد؛ هر کدام درصد خودش را میگیرد (مستقل، نه تقسیمشده) و همه در یک
FinancialBreakdownثبت میشوند — پسsecretary_share_rialsمجموع است وsecretary_user_idبرای گزارش تکنفره کافی نیست. اگر بیش از یک منشی سهمبر است، به ازای هر منشی یک ردیف کمکیsecretary_shares(JSON روی همان breakdown) ذخیره کن و گزارش پنل منشی را از همان بخوان؛ تصمیم را در docblock مستند کن. verifiedشبا: افزودن شبا توسط خود منشی، ولیverifiedفقط از سمت ادمین ست میشود (همان قاعدهٔ نماینده)؛ تسویه فقط با شبای تأییدشده.- الگوهای پروژه: پاسخها با
$this->success()/$this->paginated()/$this->error()· لیستها باgetArrayResult()· تاریخها Unix timestamp و نمایش باformatDate()شمسی · فرانت: TanStack Query v5،SearchableSelectبهجای<select>، کامپوننتها و توکنهای موجود بدون طراحی تازه · رشتههای UI فارسی. - بعد از اتمام:
graphify update .(پس از commit).