Files
clinicpro/.claude/prompt/secretary-online-appointment-share.md
hamed 8d2b0d908a 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.
2026-07-25 18:34:18 +03:30

23 KiB
Raw Permalink Blame History

سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)

زمینه

نوبتی که از سایت عمومی به‌صورت آنلاین رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: 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_limitremoveIban($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/:uuidSecretaryDetailPage با RoleRoute roles={['admin']}.
  • assets/admin/pages/SecretaryDetailPage.tsx (جدید) با الگوی RepresentationDetailPage.tsxPageHeader + کارت‌های موجود، بدون طراحی تازه:
    • کارت «اطلاعات منشی» (نام، موبایل، پزشک/کلینیک، وضعیت، مجوزها)
    • کارت «سهم درآمد نوبت‌های آنلاین»: سوییچ فعال/غیرفعال + ورودی درصد (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).