Files
clinicpro/.claude/prompt/representation-domain-guard-dashboard-settlement.md
hamed 9603b702c1 feat: implement domain guard for commission calculation and enhance representation dashboard
- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor.
- Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative.
- Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports.
- Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests.
- Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
2026-06-24 16:14:41 +03:30

16 KiB
Raw Permalink Blame History

گاردِ دامنه برای پورسانت + داشبورد/گزارش/تسویه‌ی واقعی نماینده

پروژه

clinicpro (Backend Symfony + پنل ادمین React). cross-repo: یک پرامپت همتا در nobat724_front/.claude/prompt/booking-send-representation.md وجود دارد که سایت عمومی را وادار می‌کند هنگام رزرو، شناسه‌ی نماینده‌ی دامنه را بفرستد. این پرامپت backend را اول اجرا کن، سپس پرامپت front را.

زمینه

موتور مالی نماینده قبلاً ساخته شده (CommissionService, FinancialBreakdown, کلیدهای SiteConfig، گزارش ادمین). اما سه شکاف باقی است:

  1. گاردِ دامنه وجود ندارد. CommissionService::processAppointment نماینده را فقط از doctor.getRepresentationId() می‌گیرد. درخواست کسب‌وکار: پورسانت فقط وقتی محاسبه شود که هم نوبت از دامنه‌ی همان نماینده ثبت شده باشد و پزشک/کلینیک هم متعلق به همان نماینده باشد. الان شرط دامنه چک نمی‌شود.
  2. RepresentationController::buildStats به نماینده فیلتر نمی‌شودtotal_payments/total_revenue_rials/total_appointments کلِ پلتفرم را می‌شمارد (هیچ representationId در WHERE نیست). هر نماینده آمار همه را می‌بیند. این باگِ «داشبورد اطلاعات درست نشان نمی‌دهد» است.
  3. داشبورد نماینده (RepresentationDashboard در DashboardPage.tsx) فقط ۴ کارت ماهانه/سالانه دارد؛ آمار امروز/هفته، لیست پزشکان با درآمد واقعی، و بخش تسویه ندارد.

زیرساخت تسویه از قبل کامل است و به User کلید خورده (همان user نماینده): GET /api/v1/wallet/balance, GET /api/v1/wallet/transactions, POST /api/v1/settlement, GET /api/v1/settlement, POST /api/v1/settlement/{uuid}/approve|reject (ادمین). نیازی به مدل تسویه‌ی جدید نیست — فقط در UI نماینده مصرف شود.

مشکل / هدف

  • گاردِ دامنه: book() شناسه‌ی نماینده‌ی دامنه را بپذیرد؛ پورسانت فقط وقتی واریز شود که با doctor.representationId یکی باشد.
  • buildStats و داشبورد نماینده به‌درستی scope شوند (فقط پزشکان/کلینیک‌ها و درآمدِ همان نماینده).
  • درآمد بر اساس پورسانت واقعیِ ثبت‌شده (FinancialBreakdown.representation_share_rials) نه مبلغ کل نوبت.
  • endpointهای جدید برای: خلاصه‌ی داشبورد نماینده (امروز/هفته/ماه/کل + درآمدِ قابل‌تسویه/تسویه‌شده/درانتظار)، عملکرد پزشکان، گزارش مالی بازه‌ای.

فایل‌های مرتبط

فایل نقش
clinicpro/src/Appointment/Controller/AppointmentController.php book() — دریافت و ذخیره‌ی نماینده‌ی دامنه روی نوبت
clinicpro/src/Appointment/Entity/Appointment.php افزودن فیلد bookingRepresentationId (نماینده‌ای که نوبت از دامنه‌اش ثبت شد) + migration
clinicpro/src/Settlement/Service/CommissionService.php افزودن گاردِ دامنه به processAppointment
clinicpro/src/Payment/Controller/PaymentController.php پاس‌دادن bookingRepresentationId نوبت به processAppointment
clinicpro/src/Representation/Controller/RepresentationController.php اصلاح buildStats (scope به نماینده) + endpointهای جدید داشبورد/پزشکان/گزارش
clinicpro/src/Settlement/Repository/SettlementRepository.php متدهای جمع‌بندی (paid/pending) برای یک user
clinicpro/src/Settlement/Entity/FinancialBreakdown.php منبع درآمد واقعی نماینده (موجود)
clinicpro/assets/admin/pages/DashboardPage.tsx بازنویسی RepresentationDashboard
clinicpro/assets/admin/pages/ صفحه‌ی جدید RepresentationSettlementPage.tsx (تسویه نماینده) + RepresentationFinancePage.tsx (گزارش بازه‌ای)
clinicpro/assets/admin/App.tsx, components/layout/Sidebar.tsx route و لینک نماینده
clinicpro/docs/api/representation.md, appointment.md, settlement.md مستندسازی

وضعیت فعلی

الف) buildStats به نماینده فیلتر نمی‌شود (باگ)

// RepresentationController::buildStats — هیچ representationId در WHERE نیست
private function buildStats(int $startTs, int $endTs): array
{
    $totalPayments = (int) $this->em->createQuery(
        'SELECT COUNT(p.id) FROM App\Payment\Entity\Payment p
         WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
    )->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
     ->getSingleScalarResult();
    // ... total_revenue, total_appointments هم همگی کلِ پلتفرم
    return ['total_payments' => ..., 'total_revenue_rials' => ..., 'total_appointments' => ...];
}

ب) CommissionService بدون گاردِ دامنه

public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
{
    if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
    $rep = $this->resolveRep($representationId);   // فقط از doctor.representationId
    if ($rep === null) return;
    $this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, (float) $rep->getCommissionPercent(), $rep, $doctorId, null);
}

فراخوانی فعلی در PaymentController::handleAppointmentConfirmation:

$doctor = $appointment->getDoctor();
$this->commissionService->processAppointment($payment, $doctor->getRepresentationId(), $doctor->getId());

ج) booking فعلی (AppointmentController::book) — نماینده ارسال/ذخیره نمی‌شود

book() فقط doctor_uuid, slot_start/end, for_self, اطلاعات بیمار و حالا patient_national_code/patient_gender را می‌گیرد. هیچ representation_uuid دریافت یا ذخیره نمی‌شود.

د) RepresentationDashboard فعلی (DashboardPage.tsx) — فقط ۴ کارت

// فقط monthly/yearly از buildStats؛ آمار امروز/هفته، لیست پزشکان، تسویه ندارد
const cards = [
  { label: 'نوبت‌های این ماه', value: formatNumber(monthly?.total_appointments ?? 0), ... },
  { label: 'کمیسیون این ماه',  value: formatRial(monthly?.commission_rials ?? 0), ... },
  { label: 'نوبت‌های امسال',   value: formatNumber(yearly?.total_appointments ?? 0), ... },
  { label: 'کمیسیون امسال',    value: formatRial(yearly?.commission_rials ?? 0), ... },
];

توجه: داشبورد فعلی commission_rials می‌خواند ولی buildStats آن را برنمی‌گرداند → همیشه ۰.

وظایف

۱. ذخیره‌ی نماینده‌ی دامنه روی نوبت

Appointment فیلد جدید bookingRepresentationId (nullable int). سایت city_id دامنه‌ی جاری را می‌فرستد (matchedCity.id که همیشه در دسترس است؛ uuid نماینده را ندارد). backend نماینده‌ی آن شهر را پیدا می‌کند:

// city_id از payload سایت (getStateInfo().matchedCity.id)
$cityId = (int) ($data['city_id'] ?? 0);
if ($cityId > 0) {
    $rep = $this->representationRepo->findActiveByCityId($cityId);
    if ($rep !== null) {
        $appointment->setBookingRepresentationId($rep->getId());
    }
}

متد جدید RepresentationRepository::findActiveByCityId(int $cityId): ?Representation (findOneBy(['cityId' => $cityId, 'active' => true])). migration لازم (doctrine:migrations:diff + migrate). RepresentationRepository به AppointmentController تزریق شود.

چون Representation.cityId به categories.id (bundle=city) خورده و city.json[].id در سایت همان مقدار است، نگاشت مستقیم است. اگر شهری نماینده نداشته باشد bookingRepresentationId=null می‌ماند.

۲. گاردِ دامنه در CommissionService

processAppointment یک پارامتر سوم برای نماینده‌ی دامنه بگیرد و فقط وقتی محاسبه کند که با نماینده‌ی پزشک یکی باشد:

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;
    $this->settle(...);
}

در PaymentController::handleAppointmentConfirmation:

$doctor = $appointment->getDoctor();
$this->commissionService->processAppointment(
    $payment,
    $doctor->getRepresentationId(),
    $appointment->getBookingRepresentationId(),
    $doctor->getId(),
);

اشتراک (processSubscription) دامنه ندارد؛ همان منطق representationId بماند.

۳. اصلاح buildStats به scope نماینده + درآمد واقعی

buildStats پارامتر Representation $rep بگیرد و کوئری‌ها به پزشکان/کلینیک‌های همان نماینده محدود شوند:

  • total_appointments: JOIN a.doctor d WHERE d.representationId = :repId AND a.createdAt BETWEEN ...
  • total_revenue_rials (مبلغ کل نوبت‌های confirmed آن نماینده) و مهم‌تر:
  • commission_rials: SUM(b.representationShareRials) FROM FinancialBreakdown b WHERE b.representationId = :repId AND b.createdAt BETWEEN ...درآمد واقعیِ ثبت‌شده، نه تخمین.

۴. endpointهای جدید داشبورد نماینده

همه #[IsGranted('ROLE_REPRESENTATION')]، نماینده از #[CurrentUser] (نه از uuid مسیر — تا نماینده داده‌ی دیگری نبیند). طبق الگوی RepresentationActionController که قبلاً ساخته شد.

  • GET /api/v1/representation/dashboard/summary

    {
      "appointments": { "today": 0, "week": 0, "month": 0, "total": 0 },
      "income": {
        "today": 0, "week": 0, "month": 0, "total": 0,
        "settlable_rials": 0,   // = getWalletBalance(repUser)
        "settled_rials": 0,     // SUM(Settlement paid)
        "pending_rials": 0      // SUM(Settlement pending+approved)
      }
    }
    

    درآمد از FinancialBreakdown.representationShareRials بازه‌ای؛ شمارش نوبت از Appointment JOIN doctor WHERE representationId. بازه‌ها: امروز/هفته/ماه با Unix timestamp (شروع روز/هفته/ماهِ جاری). هفته و ماه را شمسی محاسبه نکن مگر لازم باشد؛ ساده: امروز = strtotime('today'), هفته = ۷ روز اخیر، ماه = ۳۰ روز اخیر (در نکات تأیید بگیر).

  • GET /api/v1/representation/doctors/performance (paginated) → برای هر پزشکِ نماینده:

    { "uuid": "...", "name": "...", "appointments": { "today":0,"week":0,"month":0,"total":0 },
      "representation_income_rials": 0, "subscription_status": "active|expired|none" }
    

    درآمد هر پزشک = SUM(FinancialBreakdown.representationShareRials WHERE doctorId = ...). وضعیت اشتراک از SubscriptionService::getActiveSubscription('doctor', doctorId).

  • GET /api/v1/representation/finance/report?from=&to= (paginated) → ردیف‌های FinancialBreakdown همان نماینده با جزئیات: appointment uuid، نام پزشک، مبلغ نوبت (gross)، مالیات، هزینه پیامک، درصد و مبلغ پورسانت، تاریخ، و وضعیت تسویه. بازه‌ی پیش‌فرض: ماه جاری. join به Payment.appointment برای نام پزشک.

۵. متدهای جمع‌بندی تسویه در SettlementRepository

public function sumByStatus(User $user, array $statuses): int  // SUM(amountRials) WHERE user AND status IN (...)

برای settled_rials (status=paid) و pending_rials (status IN pending,approved).

۶. بازنویسی RepresentationDashboard (DashboardPage.tsx)

از endpointهای جدید استفاده کن:

  • ردیف کارت‌های نوبت: امروز/هفته/ماه/کل
  • ردیف کارت‌های درآمد: امروز/هفته/ماه/کل + قابل‌تسویه/تسویه‌شده/درانتظار (با formatRial)
  • جدول «عملکرد پزشکان» (DataTable): نام، نوبت‌ها (امروز/هفته/ماه/کل)، درآمد نماینده، وضعیت اشتراک (StatusBadge)
  • لینک به صفحه‌ی تسویه و گزارش مالی

۷. صفحات جدید نماینده

  • RepresentationSettlementPage.tsx: موجودی قابل‌برداشت (/wallet/balance)، مجموع تسویه‌شده/درانتظار (از summary)، لیست درخواست‌ها (GET /api/v1/settlement)، فرم ثبت درخواست جدید (POST /api/v1/settlement) با اعتبارسنجی مبلغ ≤ موجودی. وضعیت‌ها: pending/approved/rejected/paid با StatusBadge.
  • RepresentationFinancePage.tsx: فیلتر بازه (امروز/هفته/ماه/دلخواه با PersianDateInput)، جدول از /representation/finance/report.
  • route در App.tsx با RoleRoute roles={['representation']}؛ لینک در Sidebar.tsx (بخش نماینده).

نکات مهم

  • گاردِ دامنه: پورسانت فقط وقتی که doctorRepId === bookingRepId و هر دو غیرnull. اگر نوبت بدون representation_uuid ثبت شود (دامنه‌ی غیر نماینده) → bookingRepId=null → بدون پورسانت. این رفتار مطلوب است.
  • idempotency: CommissionService::settle از قبل با existsForPayment محافظت می‌شود؛ دست نزن.
  • درآمد همیشه از FinancialBreakdown.representationShareRials — هرگز از مبلغ کل نوبت یا تخمین درصد. کاربر صریحاً گفت «پورسانت واقعیِ ثبت‌شده، نه مبلغ کل».
  • scope نماینده در همه‌ی endpointها از #[CurrentUser] نه از uuid مسیر؛ نماینده نباید داده‌ی نماینده‌ی دیگر را ببیند (الگوی RepresentationActionController).
  • ترتیب محاسبه‌ی پورسانت دست‌نخورده می‌ماند: پیامک → مالیات → درصد روی خالص (در CommissionService::settle).
  • تاریخ‌ها Unix timestamp صحیح؛ نمایش با formatDate/formatRial. تعریف دقیق «هفته/ماه» (۷/۳۰ روز اخیر یا شروع هفته/ماه شمسی) را در ابتدای اجرا با کاربر تأیید کن.
  • همه controllerها از BaseController؛ لیست‌ها paginated() + getArrayResult().
  • Admin frontend: paginated → data?.data + data?.meta?.totalRecords؛ single → data?.data (دقت به double-nest در success(['data'=>...])).
  • بعد از تغییر Entity: migration. بعد از تغییر API: docs/api/representation.md, appointment.md, settlement.md. سپس ddev exec yarn dev (صحت TS) و graphify update ..
  • مسیرهای رزرو ادمین/منشی پرداخت آنلاین ندارند؛ گاردِ دامنه فقط روی مسیر عمومی POST /api/v1/appointment معنا دارد.