- 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.
16 KiB
گاردِ دامنه برای پورسانت + داشبورد/گزارش/تسویهی واقعی نماینده
پروژه
clinicpro (Backend Symfony + پنل ادمین React). cross-repo: یک پرامپت همتا در nobat724_front/.claude/prompt/booking-send-representation.md وجود دارد که سایت عمومی را وادار میکند هنگام رزرو، شناسهی نمایندهی دامنه را بفرستد. این پرامپت backend را اول اجرا کن، سپس پرامپت front را.
زمینه
موتور مالی نماینده قبلاً ساخته شده (CommissionService, FinancialBreakdown, کلیدهای SiteConfig، گزارش ادمین). اما سه شکاف باقی است:
- گاردِ دامنه وجود ندارد.
CommissionService::processAppointmentنماینده را فقط ازdoctor.getRepresentationId()میگیرد. درخواست کسبوکار: پورسانت فقط وقتی محاسبه شود که هم نوبت از دامنهی همان نماینده ثبت شده باشد و پزشک/کلینیک هم متعلق به همان نماینده باشد. الان شرط دامنه چک نمیشود. RepresentationController::buildStatsبه نماینده فیلتر نمیشود —total_payments/total_revenue_rials/total_appointmentsکلِ پلتفرم را میشمارد (هیچrepresentationIdدر WHERE نیست). هر نماینده آمار همه را میبیند. این باگِ «داشبورد اطلاعات درست نشان نمیدهد» است.- داشبورد نماینده (
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معنا دارد.