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:
@@ -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` معنا دارد.
|
||||
Reference in New Issue
Block a user