From 148d033114a6eb287020e2f612fc6dda68b90beb Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 24 Jun 2026 13:06:17 +0330 Subject: [PATCH] feat: Implement financial engine for commission and tax calculations - Added new configuration keys for appointment and upgrade commissions, tax settings, and SMS panel fee in SiteConfigController and SiteConfigRepository. - Introduced CommissionService to handle commission calculations for appointments and subscriptions, including tax deductions and SMS fees. - Created FinancialBreakdown entity and repository to log financial transactions. - Updated PaymentController to process commissions upon successful payments for appointments and subscriptions. - Developed FinancialReportPage in the admin panel to display financial breakdowns and summaries. - Added database migration for the new financial_breakdowns table. --- .../representation-commission-tax-engine.md | 330 ++++++++++++++++++ assets/admin/App.tsx | 2 + assets/admin/components/layout/Sidebar.tsx | 5 + assets/admin/pages/FinancialReportPage.tsx | 123 +++++++ assets/admin/pages/SettingsPage.tsx | 120 +++++++ docs/api/admin.md | 69 ++++ docs/api/payment.md | 2 + docs/api/settlement.md | 6 + migrations/Version20260624092459.php | 35 ++ src/Admin/Controller/AdminApiController.php | 108 ++++++ .../Controller/SiteConfigController.php | 7 + .../Repository/SiteConfigRepository.php | 7 + src/Payment/Controller/PaymentController.php | 10 + src/Settlement/Entity/FinancialBreakdown.php | 135 +++++++ .../FinancialBreakdownRepository.php | 27 ++ src/Settlement/Service/CommissionService.php | 133 +++++++ 16 files changed, 1119 insertions(+) create mode 100644 .claude/prompt/representation-commission-tax-engine.md create mode 100644 assets/admin/pages/FinancialReportPage.tsx create mode 100644 migrations/Version20260624092459.php create mode 100644 src/Settlement/Entity/FinancialBreakdown.php create mode 100644 src/Settlement/Repository/FinancialBreakdownRepository.php create mode 100644 src/Settlement/Service/CommissionService.php diff --git a/.claude/prompt/representation-commission-tax-engine.md b/.claude/prompt/representation-commission-tax-engine.md new file mode 100644 index 00000000..f11b1d10 --- /dev/null +++ b/.claude/prompt/representation-commission-tax-engine.md @@ -0,0 +1,330 @@ +# موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React). کاملاً داخل همین پروژه است؛ cross-repo نیست. +سایت عمومی `nobat724_front` فقط مصرف‌کننده‌ی مبلغِ نهایی نوبت است و در این تغییر دست نمی‌خورد (مبلغ پرداختی کاربر تغییر نمی‌کند؛ فقط تقسیمِ پس از پرداخت در بک‌اند اضافه می‌شود). + +## زمینه + +هر `Representation` یک فیلد `commission_percent` دارد که در `/admin/representations` ذخیره و ویرایش می‌شود، **اما در هیچ محاسبه‌ی مالی استفاده نمی‌شود** — صرفاً نمایشی است. هر `Doctor` و هر `Clinic` فیلد `representationId` دارند (نماینده‌ای که آن‌ها را اضافه کرده). هنگام موفق‌شدن پرداخت، `PaymentController` فقط نوبت را `confirmed` یا اشتراک را فعال می‌کند و **هیچ سهمی به کیف‌پول نماینده واریز نمی‌شود**. + +کیف‌پول نماینده با `WalletTransaction` (credit/debit + `balance_after`) و موجودی با `SettlementRepository::getWalletBalance(User)` مدیریت می‌شود؛ نماینده از طریق `Settlement` برداشت می‌کند. تنظیمات سراسری در `SiteConfig` (کلید/مقدار) با whitelist در `SiteConfigController::ALLOWED_KEYS` و defaults در `SiteConfigRepository::DEFAULTS` نگه‌داری می‌شوند. کلیدهای `commission_enabled` و `commission_percent` از قبل تعریف شده ولی بلااستفاده‌اند. + +## مشکل / هدف + +۱. هنگام پرداخت موفقِ **نوبت** برای پزشکی که `representationId` دارد، سهم نماینده محاسبه و به کیف‌پولش واریز شود (درصد = `commission_percent` همان نماینده). +۲. هنگام پرداخت موفقِ **ارتقاء/خرید اشتراک** پزشک یا کلینیکی که `representationId` دارد، درصدِ ارتقاء (پیش‌فرض ۲۰٪، **غیر هاردکد**، از تنظیمات ادمین) به کیف‌پول نماینده واریز شود. +۳. **مالیات بر ارزش افزوده**: همه‌ی مبالغ شامل مالیات‌اند. درصد مالیات + فعال/غیرفعال + (اختیاری) تاریخچه از پنل ادمین کنترل شود. برای هر تراکنش مبلغ مالیات ذخیره شود. +۴. **هزینه ثابت پنل پیامک**: مبلغ ثابت ۱۵۰٬۰۰۰ تومان = **۱٬۵۰۰٬۰۰۰ ریال** از مبلغ نوبت کسر شود (مبلغ از تنظیمات ادمین قابل کنترل). +۵. **ترتیب محاسبه (بسیار مهم)**: ۱) کسر هزینه پنل پیامک، ۲) کسر مالیات از باقی‌مانده، ۳) پورسانت نماینده از **مبلغ خالصِ پس از مالیات**. +۶. ثبت لاگ کامل مالی برای هر تراکنش + مشاهده/گزارش‌گیری در پنل ادمین. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Config/Controller/SiteConfigController.php` | افزودن کلیدهای مالی به `ALLOWED_KEYS` | +| `clinicpro/src/Config/Repository/SiteConfigRepository.php` | افزودن `DEFAULTS` کلیدهای مالی | +| `clinicpro/src/Settlement/Entity/FinancialBreakdown.php` (جدید) | Entity لاگ تفکیک مالی هر تراکنش | +| `clinicpro/src/Settlement/Repository/FinancialBreakdownRepository.php` (جدید) | repository + کوئری گزارش‌ها | +| `clinicpro/src/Settlement/Service/CommissionService.php` (جدید) | منطق محاسبه (ترتیب کسر) + واریز کیف‌پول + ثبت لاگ | +| `clinicpro/src/Payment/Controller/PaymentController.php` | فراخوانی `CommissionService` در `handleAppointmentConfirmation` و `handleSubscriptionActivation` | +| `clinicpro/src/Representation/Entity/Representation.php` | منبع `commission_percent` نوبتِ هر نماینده (موجود) | +| `clinicpro/src/Doctor/Entity/Doctor.php` / `Clinic/Entity/Clinic.php` | `getRepresentationId()` (موجود) | +| `clinicpro/src/Representation/Repository/RepresentationRepository.php` | افزودن `find(int $id)` برای یافتن نماینده از روی `representationId` | +| `clinicpro/src/Settlement/Repository/SettlementRepository.php` | `getWalletBalance(User)` (موجود) برای محاسبه `balance_after` | +| `clinicpro/src/Admin/Controller/AdminApiController.php` | endpointهای گزارش مالی (لیست breakdown + جمع‌ها) | +| `clinicpro/assets/admin/pages/SettingsPage.tsx` | فرم تنظیمات مالی | +| `clinicpro/assets/admin/pages/FinancialReportPage.tsx` (جدید) | جدول تراکنش‌ها + کارت‌های جمع | +| `clinicpro/docs/api/admin.md`، `docs/api/settlement.md`، `docs/api/payment.md` | مستندسازی | + +## وضعیت فعلی + +### الف) هوک پرداخت موفق — هیچ پورسانتی واریز نمی‌شود + +`clinicpro/src/Payment/Controller/PaymentController.php` + +```php +$payment->setStatus(Payment::STATUS_SUCCESS); +$payment->setReferenceId($result->referenceId); +$this->paymentRepo->save($payment); + +if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) { + $this->handleSubscriptionActivation($payment); +} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) { + $this->handleSmsWalletCharge($payment); +} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) { + $this->handleAppointmentConfirmation($payment); +} +``` + +```php +private function handleAppointmentConfirmation(Payment $payment): void +{ + $appointment = $payment->getAppointment(); + if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) { + return; + } + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->appointmentRepo->save($appointment); + // ... فقط SMS؛ هیچ پورسانت/مالیاتی نیست +} + +private function handleSubscriptionActivation(Payment $payment): void +{ + $meta = $payment->getMetadata() ?? []; + $periodUuid = $meta['period_uuid'] ?? null; + if ($periodUuid === null) return; + + $user = $payment->getUser(); + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor !== null) { + $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid); + return; + } + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic !== null) { + $this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid); + } +} +``` + +### ب) `commission_percent` بلااستفاده + +`Representation::getCommissionPercent(): string` (decimal 5,2، پیش‌فرض `'10.00'`) فقط در `RepresentationController` و خروجی admin خوانده/نوشته می‌شود؛ در هیچ محاسبه‌ی مالی به‌کار نمی‌رود. + +### ج) الگوی واریز کیف‌پول (از `SettlementController::reject`) + +```php +$balance = $this->settlementRepo->getWalletBalance($settlement->getUser()); +$tx = new WalletTransaction( + $settlement->getUser(), + $settlement->getAmountRials(), + WalletTransaction::TYPE_CREDIT, + $balance + $settlement->getAmountRials() +); +$tx->setDescription('...'); +$this->walletRepo->save($tx); +``` + +### د) تنظیمات ادمین (whitelist + defaults) + +```php +// SiteConfigController::ALLOWED_KEYS — فعلاً: commission_enabled, commission_percent, ... +// SiteConfigRepository::DEFAULTS — 'commission_enabled' => '0', 'commission_percent' => '0', ... +// GET/PATCH /api/v1/admin/settings از قبل کار می‌کند +``` + +## وظایف + +### ۱. کلیدهای تنظیمات مالی در `SiteConfig` + +به `SiteConfigController::ALLOWED_KEYS` و `SiteConfigRepository::DEFAULTS` این کلیدها اضافه شود (همه به‌صورت رشته ذخیره می‌شوند): + +```php +// ALLOWED_KEYS + DEFAULTS +'appointment_commission_enabled' => '0', // پورسانت نوبت فعال؟ (درصد از خودِ نماینده خوانده می‌شود) +'upgrade_commission_enabled' => '0', // پورسانت ارتقاء اشتراک فعال؟ +'upgrade_commission_percent' => '20', // درصد پورسانت ارتقاء (غیر هاردکد) +'tax_enabled' => '0', // مالیات بر ارزش افزوده فعال؟ +'tax_percent' => '10', // درصد مالیات بر ارزش افزوده +'sms_panel_fee_rials' => '1500000',// هزینه ثابت پنل پیامک به ریال (۱۵۰٬۰۰۰ تومان) +``` + +> توجه: `commission_percent`/`commission_enabled` قدیمی بلااستفاده‌اند؛ دست نزن یا در همین تسک منسوخ‌شان کن (در `SettingsPage` پنهان کن). کلید پورسانت نوبت اکنون `appointment_commission_enabled` + درصدِ هر نماینده است. + +### ۲. Entity لاگ مالی `FinancialBreakdown` + +فایل جدید `clinicpro/src/Settlement/Entity/FinancialBreakdown.php`. برای هر تراکنشی که مشمول قانون مالی می‌شود یک ردیف ثبت شود. فیلدها (همگی ریال، تاریخ‌ها Unix timestamp صحیح): + +```php +#[ORM\Entity] +#[ORM\Table(name: 'financial_breakdowns')] +class FinancialBreakdown +{ + public const SOURCE_APPOINTMENT = 'appointment'; + public const SOURCE_SUBSCRIPTION = 'subscription'; + + private ?int $id; + private string $uuid; // Uuid::v4()->toRfc4122() + #[ORM\ManyToOne(targetEntity: Payment::class)] private Payment $payment; + private string $source; // appointment | subscription + private int $grossRials; // مبلغ اولیه تراکنش + private int $smsFeeRials; // کسر پنل پیامک + private string $taxPercent; // decimal 5,2 — درصد مالیاتِ اعمال‌شده + private int $taxRials; // مبلغ مالیات + private int $netAfterTaxRials; // خالص پس از پیامک و مالیات + private string $commissionPercent; // decimal 5,2 — درصد پورسانتِ اعمال‌شده + private int $representationShareRials;// سهم نماینده + private int $systemShareRials; // سهم سیستم + private ?int $representationId = null; + private ?int $doctorId = null; + private ?int $clinicId = null; + #[ORM\ManyToOne(targetEntity: User::class)] private User $user; // پرداخت‌کننده + private int $createdAt; + // getters + toArray() +} +``` + +`toArray()` همه‌ی این مبالغ + ids را برگرداند تا در گزارش ادمین نمایش داده شوند. + +migration لازم است: `ddev exec php bin/console doctrine:migrations:diff --no-interaction` سپس `migrate`. + +### ۳. سرویس محاسبه `CommissionService` + +فایل جدید `clinicpro/src/Settlement/Service/CommissionService.php`. مسئول: محاسبه با **ترتیب دقیق**، واریز کیف‌پول نماینده، ثبت `FinancialBreakdown`. + +```php +class CommissionService +{ + public function __construct( + private SiteConfigRepository $configRepo, + private RepresentationRepository $representationRepo, + private SettlementRepository $settlementRepo, // getWalletBalance + private WalletTransactionRepository $walletRepo, + private FinancialBreakdownRepository $breakdownRepo, + private EntityManagerInterface $em, + ) {} + + /** + * نوبت: درصد پورسانت = commission_percent همان نماینده. + * هزینه پیامک فقط در مسیر نوبت کسر می‌شود. + */ + public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void + { + if ($this->configRepo->get('appointment_commission_enabled') !== '1') return; + $rep = $representationId ? $this->representationRepo->find($representationId) : null; + if ($rep === null || !$rep->isActive()) return; + + $smsFee = (int) $this->configRepo->get('sms_panel_fee_rials'); + $percent = (float) $rep->getCommissionPercent(); + $this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, $smsFee, $percent, $rep, $doctorId, null); + } + + /** + * اشتراک/ارتقاء: درصد = upgrade_commission_percent (سراسری). بدون کسر هزینه پیامک. + */ + public function processSubscription(Payment $payment, ?int $representationId, ?int $doctorId, ?int $clinicId): void + { + if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return; + $rep = $representationId ? $this->representationRepo->find($representationId) : null; + if ($rep === null || !$rep->isActive()) return; + + $percent = (float) $this->configRepo->get('upgrade_commission_percent'); + $this->settle($payment, FinancialBreakdown::SOURCE_SUBSCRIPTION, 0, $percent, $rep, $doctorId, $clinicId); + } + + private function settle( + Payment $payment, string $source, int $smsFee, float $commissionPercent, + Representation $rep, ?int $doctorId, ?int $clinicId + ): void { + $gross = $payment->getAmountRials(); + + // مرحله ۱: کسر هزینه ثابت پنل پیامک + $afterSms = max(0, $gross - $smsFee); + + // مرحله ۲: محاسبه و کسر مالیات از باقی‌مانده + $taxEnabled = $this->configRepo->get('tax_enabled') === '1'; + $taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0; + // مبالغ شامل مالیات‌اند ⇒ مالیات از مبلغِ مشمول استخراج می‌شود. + // در run-prompt با کاربر تأیید کن: «استخراج از مبلغِ شامل مالیات» یا «افزودن روی مبلغ». + $taxRials = $taxEnabled ? (int) round($afterSms * $taxPercent / (100 + $taxPercent)) : 0; + $netAfterTax = $afterSms - $taxRials; + + // مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات + $repShare = (int) round($netAfterTax * $commissionPercent / 100); + $systemShare = $gross - $smsFee - $taxRials - $repShare; + + // واریز کیف‌پول نماینده (الگوی SettlementController) + $repUser = $rep->getUser(); + $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); + + // ثبت لاگ مالی + $bd = new FinancialBreakdown(/* ... همه‌ی مبالغ، درصدها، ids ... */); + $this->breakdownRepo->save($bd, false); + + $this->em->flush(); + } +} +``` + +نکات محاسبه: +- اگر `repShare <= 0` بود (مثلاً مبلغ پس از کسرها صفر شد) واریز کیف‌پول انجام نشود ولی لاگ با مقادیر صفر همچنان ثبت شود (برای گزارش). +- همه‌ی مبالغ صحیح ریال؛ از `round` برای جلوگیری از خطای ممیز استفاده شود. + +### ۴. اتصال به `PaymentController` + +`CommissionService` به constructor تزریق شود و در دو هوک فراخوانی شود. + +در `handleAppointmentConfirmation` بعد از `confirmed` شدن: +```php +$doctor = $appointment->getDoctor(); +$this->commissionService->processAppointment( + $payment, + $doctor->getRepresentationId(), + $doctor->getId(), +); +``` + +در `handleSubscriptionActivation` بعد از فعال‌سازی اشتراک، با تشخیص doctor/clinic: +```php +// مسیر doctor: +$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null); +// مسیر clinic: +$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId()); +``` + +> idempotency: مطمئن شو یک پرداخت دوبار پردازش نشود (verify فقط یک‌بار success می‌شود؛ ولی برای اطمینان می‌توان قبل از ثبت، نبودِ `FinancialBreakdown` با همان `payment_id` را بررسی کرد). + +### ۵. `RepresentationRepository::find(int $id)` + +متد یافتن نماینده از روی `representationId` (که روی Doctor/Clinic ذخیره است). `ServiceEntityRepository` به‌صورت پیش‌فرض `find()` دارد؛ فقط مطمئن شو در سرویس از `->find($id)` استفاده می‌شود. + +### ۶. endpointهای گزارش مالی در `AdminApiController` + +طبق الگوی پروژه (`getArrayResult()` + `$this->paginated()` برای لیست، `$this->success()` برای جمع‌ها): + +- `GET /api/v1/admin/financial-breakdowns` (paginated): لیست تراکنش‌ها با تفکیک کامل + join نام نماینده/پزشک/کلینیک. فیلتر اختیاری `representation_id`, `source`, بازه‌ی تاریخ. +- `GET /api/v1/admin/financial-summary`: جمع‌ها → `total_representation_income`, `total_tax_collected`, `total_sms_fee`, `total_system_share`, `total_gross`. + +هر دو `#[IsGranted('ROLE_ADMIN')]`. + +### ۷. پنل ادمین — تنظیمات + گزارش + +**`SettingsPage.tsx`**: بخش «تنظیمات مالی» با فیلدهای: +- فعال/غیرفعال پورسانت نوبت (`appointment_commission_enabled`) +- فعال/غیرفعال + درصد پورسانت ارتقاء (`upgrade_commission_enabled`, `upgrade_commission_percent`) +- فعال/غیرفعال + درصد مالیات (`tax_enabled`, `tax_percent`) +- هزینه پنل پیامک به ریال (`sms_panel_fee_rials`) — نمایش معادل تومان کمکی +PATCH به `/api/v1/admin/settings` (الگوی موجود همان صفحه). + +**`FinancialReportPage.tsx` (جدید)** + route در `App.tsx` (`roles={['admin']}`): +- کارت‌های جمع از `financial-summary` +- جدول `DataTable` + `Pagination` از `financial-breakdowns` (مبالغ با `formatRial`، تاریخ شمسی با `formatDate`) +- لینک در `Sidebar.tsx` + +> الگوهای frontend: paginated → items از `data?.data`، total از `data?.meta?.totalRecords`؛ single (summary) → `data?.data`. JWT از `localStorage['clinicpro-auth']`. + +### ۸. (اختیاری طبق درخواست) تاریخچه‌ی تغییرات مالیات + +اگر در run-prompt لازم شد: یک Entity ساده‌ی `TaxRateHistory` (percent, enabled, changed_by, changed_at) که در `SiteConfigController::patch` هنگام تغییر `tax_percent`/`tax_enabled` یک ردیف ثبت کند + endpoint لیست. در نسخه‌ی اول می‌توان فقط روی `updated_at`ِ `SiteConfig` اکتفا کرد و این را به فاز بعد سپرد — در run-prompt با کاربر تأیید شود. + +## نکات مهم + +- **ترتیب کسرها ثابت و غیرقابل‌جابه‌جایی است**: پیامک → مالیات → پورسانت. پورسانت حتماً روی `netAfterTax` نه `gross`. +- **«همه مبالغ شامل مالیات‌اند»** ⇒ فرمول استخراجِ مالیات از مبلغِ ناخالص (`amount × p/(100+p)`) استفاده شده، نه افزودن روی آن. این تصمیم را در ابتدای run-prompt صریحاً با کاربر تأیید کن؛ اگر منظورش «افزودن مالیات روی مبلغ» باشد فرمول `amount × p/100` می‌شود. +- **هزینه پنل پیامک فقط روی نوبت** اعمال شده (طبق متن «از مبلغ نوبت کسر شود»). اگر باید روی اشتراک هم اعمال شود، در run-prompt تأیید بگیر. +- درصد پورسانتِ **نوبت** از `Representation::commission_percent` (هر نماینده جدا) و درصد **ارتقاء** از کلید سراسری `upgrade_commission_percent`. این تفکیک عمدی است. +- هیچ مبلغی نباید هاردکد شود؛ ۲۰٪ و ۱۵۰٬۰۰۰ تومان صرفاً defaults در `SiteConfig` هستند. +- همه‌ی مبالغ **ریال صحیح**‌اند (۱۵۰٬۰۰۰ تومان = ۱٬۵۰۰٬۰۰۰ ریال). در UI با `formatRial` نمایش، در محاسبه با `int`. +- تاریخ‌ها Unix timestamp صحیح‌اند (نه DateTime). نمایش شمسی با `formatDate`. +- همه‌ی controllerها از `BaseController` و پاسخ‌ها با `success/paginated/error`. +- **idempotency**: مطمئن شو واریز پورسانت برای یک پرداخت فقط یک‌بار رخ دهد. +- بعد از تغییر Entity: `doctrine:migrations:diff` + `migrate`. بعد از تغییر API: `docs/api/admin.md`, `settlement.md`, `payment.md` به‌روز شوند. +- بعد از پایان: `ddev exec php vendor/bin/phpstan analyse` و `ddev exec yarn dev` برای صحت TS، سپس `graphify update .`. +- تست محاسبه: یک سناریوی عددی واقعی در PR/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 96be04d3..5f7d82ca 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -28,6 +28,7 @@ import BlogFormPage from './pages/BlogFormPage'; import SecretariesPage from './pages/SecretariesPage'; import MyClinicPage from './pages/MyClinicPage'; import SettingsPage from './pages/SettingsPage'; +import FinancialReportPage from './pages/FinancialReportPage'; import DoctorProfilePage from './pages/DoctorProfilePage'; import MyPatientsPage from './pages/MyPatientsPage'; import NewSessionPage from './pages/NewSessionPage'; @@ -144,6 +145,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 16ff60b5..a0c1681a 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -98,6 +98,11 @@ function buildSections( icon: BanknotesIcon, label: "تسویه‌حساب", }, + { + to: "/admin/financial-report", + icon: BanknotesIcon, + label: "گزارش مالی", + }, { to: "/admin/pre-registrations", icon: ClipboardDocumentCheckIcon, diff --git a/assets/admin/pages/FinancialReportPage.tsx b/assets/admin/pages/FinancialReportPage.tsx new file mode 100644 index 00000000..b07a3e63 --- /dev/null +++ b/assets/admin/pages/FinancialReportPage.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; +import { formatRial, formatDate } from '../lib/utils'; +import DataTable, { Column } from '../components/ui/DataTable'; +import Pagination from '../components/ui/Pagination'; + +interface Breakdown { + uuid: string; + order_id: string; + source: 'appointment' | 'subscription'; + gross_rials: number; + sms_fee_rials: number; + tax_percent: number; + tax_rials: number; + net_after_tax_rials: number; + commission_percent: number; + representation_share_rials: number; + system_share_rials: number; + representation_id: number | null; + representation_name: string | null; + doctor_id: number | null; + clinic_id: number | null; + created_at: string; +} + +interface Summary { + total_gross: number; + total_representation_income: number; + total_tax_collected: number; + total_sms_fee: number; + total_system_share: number; +} + +const SOURCE_LABEL: Record = { + appointment: 'نوبت', + subscription: 'ارتقاء اشتراک', +}; + +export default function FinancialReportPage() { + const [page, setPage] = useState(1); + const [source, setSource] = useState(''); + const limit = 15; + + const summaryQ = useQuery({ + queryKey: ['financial-summary'], + queryFn: () => api.get>('/api/v1/admin/financial-summary'), + staleTime: 30_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const summary: Summary | undefined = (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data; + + const { data, isLoading } = useQuery({ + queryKey: ['financial-breakdowns', page, source], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (source) params.set('source', source); + return api.get>(`/api/v1/admin/financial-breakdowns?${params}`); + }, + }); + + const items: Breakdown[] = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + const cards = [ + { label: 'مجموع ناخالص', value: summary?.total_gross, color: 'var(--text-2)', bg: 'var(--surface-3)' }, + { label: 'درآمد نمایندگان', value: summary?.total_representation_income, color: 'var(--primary)', bg: 'var(--primary-soft)' }, + { label: 'مالیات دریافت‌شده', value: summary?.total_tax_collected, color: 'var(--warning)', bg: 'var(--warning-bg)' }, + { label: 'هزینه پنل پیامک', value: summary?.total_sms_fee, color: 'var(--violet)', bg: 'var(--violet-bg)' }, + { label: 'سهم سیستم', value: summary?.total_system_share, color: 'var(--success)', bg: 'var(--success-bg)' }, + ]; + + const columns: Column[] = [ + { key: 'order_id', header: 'شناسه سفارش', render: (r) => {r.order_id} }, + { key: 'source', header: 'نوع', render: (r) => SOURCE_LABEL[r.source] ?? r.source }, + { key: 'representation_name', header: 'نماینده', render: (r) => r.representation_name ?? '—' }, + { key: 'gross_rials', header: 'ناخالص', render: (r) => formatRial(r.gross_rials) }, + { key: 'sms_fee_rials', header: 'پیامک', render: (r) => formatRial(r.sms_fee_rials) }, + { key: 'tax_rials', header: 'مالیات', render: (r) => `${formatRial(r.tax_rials)} (${r.tax_percent}٪)` }, + { key: 'representation_share_rials', header: 'سهم نماینده', render: (r) => `${formatRial(r.representation_share_rials)} (${r.commission_percent}٪)` }, + { key: 'system_share_rials', header: 'سهم سیستم', render: (r) => formatRial(r.system_share_rials) }, + { key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) }, + ]; + + return ( +
+
+
+

گزارش مالی

+
تفکیک پورسانت، مالیات و هزینه پنل پیامک هر تراکنش
+
+
+ +
+ {cards.map((c) => ( +
+
{c.label}
+
+ {c.value === undefined ? '—' : formatRial(c.value)} +
+
+ ))} +
+ +
+ + + +
+ + + + +
+ ); +} diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx index a6e89ce5..9557aa5a 100644 --- a/assets/admin/pages/SettingsPage.tsx +++ b/assets/admin/pages/SettingsPage.tsx @@ -19,6 +19,13 @@ const schema = z.object({ }, 'درصد باید بین ۰ تا ۱۰۰ باشد'), max_cancel_hours_before: z.string(), appointment_reminder_hours: z.string(), + // financial engine + appointment_commission_enabled: z.string(), + upgrade_commission_enabled: z.string(), + upgrade_commission_percent: z.string(), + tax_enabled: z.string(), + tax_percent: z.string(), + sms_panel_fee_rials: z.string(), // payment gateways payment_test_mode: z.string(), mellat_terminal_id: z.string(), @@ -41,6 +48,12 @@ interface Settings { commission_percent: string; max_cancel_hours_before: string; appointment_reminder_hours: string; + appointment_commission_enabled: string; + upgrade_commission_enabled: string; + upgrade_commission_percent: string; + tax_enabled: string; + tax_percent: string; + sms_panel_fee_rials: string; payment_test_mode: string; mellat_terminal_id: string; mellat_username: string; @@ -86,6 +99,12 @@ export default function SettingsPage() { commission_percent: settings.commission_percent ?? '0', max_cancel_hours_before: settings.max_cancel_hours_before ?? '24', appointment_reminder_hours: settings.appointment_reminder_hours ?? '2', + appointment_commission_enabled: settings.appointment_commission_enabled ?? '0', + upgrade_commission_enabled: settings.upgrade_commission_enabled ?? '0', + upgrade_commission_percent: settings.upgrade_commission_percent ?? '20', + tax_enabled: settings.tax_enabled ?? '0', + tax_percent: settings.tax_percent ?? '10', + sms_panel_fee_rials: settings.sms_panel_fee_rials ?? '1500000', payment_test_mode: settings.payment_test_mode ?? '0', mellat_terminal_id: settings.mellat_terminal_id ?? '', mellat_username: settings.mellat_username ?? '', @@ -109,6 +128,9 @@ export default function SettingsPage() { const commissionEnabled = watch('commission_enabled') === '1'; const paymentTestMode = watch('payment_test_mode') === '1'; + const apptCommissionEnabled = watch('appointment_commission_enabled') === '1'; + const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1'; + const taxEnabled = watch('tax_enabled') === '1'; const onSubmit = (values: FormValues) => { mutation.mutate(values); @@ -253,6 +275,98 @@ export default function SettingsPage() { + {/* موتور مالی نمایندگی */} +
+
+
+ 🧮 +
+

موتور مالی نمایندگی

+
+ +

+ ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)، + و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات محاسبه می‌شود. +

+ +
+ + {/* پورسانت نوبت */} +