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.
This commit is contained in:
hamed
2026-06-24 16:14:41 +03:30
parent 89e4a424f8
commit 9603b702c1
18 changed files with 928 additions and 54 deletions
@@ -0,0 +1,205 @@
# گاردِ دامنه برای پورسانت + داشبورد/گزارش/تسویه‌ی واقعی نماینده
## پروژه
`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 به نماینده فیلتر نمی‌شود (باگ)
```php
// 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 بدون گاردِ دامنه
```php
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`:
```php
$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) — فقط ۴ کارت
```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 نماینده‌ی آن شهر را پیدا می‌کند:
```php
// 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` یک پارامتر سوم برای نماینده‌ی دامنه بگیرد و فقط وقتی محاسبه کند که با نماینده‌ی پزشک یکی باشد:
```php
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`:
```php
$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`
```json
{
"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) → برای هر پزشکِ نماینده:
```json
{ "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
```php
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` معنا دارد.