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` معنا دارد.
|
||||
@@ -29,6 +29,8 @@ import SecretariesPage from './pages/SecretariesPage';
|
||||
import MyClinicPage from './pages/MyClinicPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import FinancialReportPage from './pages/FinancialReportPage';
|
||||
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
|
||||
import RepresentationFinancePage from './pages/RepresentationFinancePage';
|
||||
import DoctorProfilePage from './pages/DoctorProfilePage';
|
||||
import MyPatientsPage from './pages/MyPatientsPage';
|
||||
import NewSessionPage from './pages/NewSessionPage';
|
||||
@@ -166,6 +168,8 @@ export default function App() {
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
||||
<Route path="representation-settlement" element={<RoleRoute roles={['representation']}><RepresentationSettlementPage /></RoleRoute>} />
|
||||
<Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
|
||||
|
||||
@@ -383,6 +383,13 @@ function buildSections(
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتها" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مالی",
|
||||
items: [
|
||||
{ to: "/admin/representation-finance", icon: CreditCardIcon, label: "گزارش مالی" },
|
||||
{ to: "/admin/representation-settlement", icon: BanknotesIcon, label: "تسویه حساب" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -979,51 +979,66 @@ function SecretaryDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function RepresentationDashboard() {
|
||||
const now = new Date();
|
||||
const jYear = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).format(now));
|
||||
const jMonth = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { month: 'numeric' }).format(now));
|
||||
interface RepSummary {
|
||||
appointments: { today: number; week: number; month: number; total: number };
|
||||
income: {
|
||||
today: number; week: number; month: number; total: number;
|
||||
settlable_rials: number; settled_rials: number; pending_rials: number;
|
||||
};
|
||||
}
|
||||
interface RepDoctorPerf {
|
||||
uuid: string; name: string;
|
||||
appointments: { today: number; week: number; month: number; total: number };
|
||||
representation_income_rials: number;
|
||||
subscription_status: 'active' | 'expired' | 'none';
|
||||
}
|
||||
|
||||
function RepresentationDashboard() {
|
||||
const meQ = useQuery({
|
||||
queryKey: ['representation-me'],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string; commission_percent: string } }>>('/api/v1/representation/me'),
|
||||
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string } }>>('/api/v1/representation/me'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rep = useMemo<any>(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]);
|
||||
const repUuid: string | undefined = rep?.uuid;
|
||||
|
||||
const monthlyQ = useQuery({
|
||||
queryKey: ['representation-monthly', repUuid, jYear, jMonth],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
|
||||
`/api/v1/representation/${repUuid}/dashboard/monthly?year=${jYear}&month=${jMonth}`,
|
||||
),
|
||||
enabled: !!repUuid,
|
||||
const summaryQ = useQuery({
|
||||
queryKey: ['representation-summary'],
|
||||
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
|
||||
staleTime: 120_000,
|
||||
});
|
||||
const yearlyQ = useQuery({
|
||||
queryKey: ['representation-yearly', repUuid, jYear],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
|
||||
`/api/v1/representation/${repUuid}/dashboard/yearly?year=${jYear}`,
|
||||
),
|
||||
enabled: !!repUuid,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summary = useMemo<RepSummary | undefined>(() => (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data, [summaryQ.data]);
|
||||
|
||||
const perfQ = useQuery({
|
||||
queryKey: ['representation-doctors-performance'],
|
||||
queryFn: () => api.get<ApiResponse<RepDoctorPerf[]>>('/api/v1/representation/doctors/performance?limit=100'),
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const monthly = useMemo<any>(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const yearly = useMemo<any>(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]);
|
||||
const doctors: RepDoctorPerf[] = perfQ.data?.data ?? [];
|
||||
|
||||
if (meQ.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const cards = [
|
||||
{ label: 'نوبتهای این ماه', value: formatNumber(monthly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'کمیسیون این ماه', value: formatRial(monthly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'نوبتهای امسال', value: formatNumber(yearly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'کمیسیون امسال', value: formatRial(yearly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
const a = summary?.appointments;
|
||||
const inc = summary?.income;
|
||||
|
||||
const apptCards = [
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(a?.today ?? 0), color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'این هفته', value: formatNumber(a?.week ?? 0), color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'این ماه', value: formatNumber(a?.month ?? 0), color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'کل نوبتها', value: formatNumber(a?.total ?? 0), color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
];
|
||||
const incomeCards = [
|
||||
{ label: 'درآمد امروز', value: formatRial(inc?.today ?? 0), color: 'var(--warning)' },
|
||||
{ label: 'درآمد این هفته', value: formatRial(inc?.week ?? 0), color: 'var(--info)' },
|
||||
{ label: 'درآمد این ماه', value: formatRial(inc?.month ?? 0), color: 'var(--success)' },
|
||||
{ label: 'درآمد کل', value: formatRial(inc?.total ?? 0), color: 'var(--violet)' },
|
||||
{ label: 'قابل تسویه', value: formatRial(inc?.settlable_rials ?? 0),color: 'var(--primary)' },
|
||||
{ label: 'تسویهشده', value: formatRial(inc?.settled_rials ?? 0), color: 'var(--text-2)' },
|
||||
{ label: 'در انتظار تسویه', value: formatRial(inc?.pending_rials ?? 0), color: 'var(--text-3)' },
|
||||
];
|
||||
const subLabel: Record<string, string> = { active: 'فعال', expired: 'منقضی', none: 'بدون اشتراک' };
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
@@ -1032,17 +1047,17 @@ function RepresentationDashboard() {
|
||||
<h1 className="section-title">داشبورد نماینده</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {rep?.full_name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => { monthlyQ.refetch(); yearlyQ.refetch(); }}>
|
||||
<button className="btn ghost sm" onClick={() => { summaryQ.refetch(); perfQ.refetch(); }}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
{cards.map(c => (
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
{apptCards.map(c => (
|
||||
<div key={c.label} className="stat">
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<c.icon style={{ width: 21, height: 21 }} />
|
||||
<CalendarDaysIcon style={{ width: 21, height: 21 }} />
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">{c.value}</div>
|
||||
@@ -1050,6 +1065,56 @@ function RepresentationDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16 }}>درآمد نماینده</h3>
|
||||
</div>
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
{incomeCards.map(c => (
|
||||
<div key={c.label} className="stat" style={{ background: 'var(--surface-3)' }}>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val" style={{ color: c.color, fontSize: 15 }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16 }}>عملکرد پزشکان</h3>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="tbl" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>پزشک</th><th>امروز</th><th>هفته</th><th>ماه</th><th>کل</th>
|
||||
<th>درآمد نماینده</th><th>اشتراک</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doctors.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>پزشکی یافت نشد</td></tr>
|
||||
)}
|
||||
{doctors.map(d => (
|
||||
<tr key={d.uuid}>
|
||||
<td>{d.name}</td>
|
||||
<td>{formatNumber(d.appointments.today)}</td>
|
||||
<td>{formatNumber(d.appointments.week)}</td>
|
||||
<td>{formatNumber(d.appointments.month)}</td>
|
||||
<td>{formatNumber(d.appointments.total)}</td>
|
||||
<td>{formatRial(d.representation_income_rials)}</td>
|
||||
<td>
|
||||
<span className={`badge ${d.subscription_status === 'active' ? 'green' : 'gray'}`}>
|
||||
{subLabel[d.subscription_status] ?? d.subscription_status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>دسترسی سریع</h3>
|
||||
@@ -1058,6 +1123,8 @@ function RepresentationDashboard() {
|
||||
<Link to="/admin/doctors" className="btn sm">پزشکان من</Link>
|
||||
<Link to="/admin/clinics" className="btn sm">کلینیکها</Link>
|
||||
<Link to="/admin/appointments" className="btn sm">نوبتها</Link>
|
||||
<Link to="/admin/representation-settlement" className="btn sm">تسویه حساب</Link>
|
||||
<Link to="/admin/representation-finance" className="btn sm">گزارش مالی</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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 Pagination from '../components/ui/Pagination';
|
||||
|
||||
interface FinanceRow {
|
||||
uuid: string;
|
||||
appointment_uuid: string | null;
|
||||
doctor_name: string | null;
|
||||
gross_rials: number;
|
||||
tax_rials: number;
|
||||
sms_fee_rials: number;
|
||||
commission_percent: number;
|
||||
representation_share_rials: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type RangeKey = 'today' | 'week' | 'month' | 'all';
|
||||
|
||||
function rangeFrom(key: RangeKey): number | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (key === 'today') return Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
|
||||
if (key === 'week') return now - 7 * 86400;
|
||||
if (key === 'month') return now - 30 * 86400;
|
||||
return null;
|
||||
}
|
||||
|
||||
const RANGE_LABEL: Record<RangeKey, string> = {
|
||||
today: 'امروز', week: 'این هفته', month: 'این ماه', all: 'همه',
|
||||
};
|
||||
|
||||
export default function RepresentationFinancePage() {
|
||||
const [range, setRange] = useState<RangeKey>('month');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['representation-finance', range, page],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
const from = rangeFrom(range);
|
||||
if (from !== null) params.set('from', String(from));
|
||||
return api.get<PaginatedResponse<FinanceRow>>(`/api/v1/representation/finance/report?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const items: FinanceRow[] = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">گزارش مالی</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درآمد ثبتشده از پورسانت نوبتها</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 'var(--gap)' }}>
|
||||
{(['today', 'week', 'month', 'all'] as RangeKey[]).map(k => (
|
||||
<button key={k} className={range === k ? 'on' : ''} onClick={() => { setRange(k); setPage(1); }}>
|
||||
{RANGE_LABEL[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="tbl" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>پزشک</th><th>مبلغ نوبت</th><th>مالیات</th><th>پیامک</th>
|
||||
<th>درصد</th><th>سهم نماینده</th><th>تاریخ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>در حال بارگذاری...</td></tr>
|
||||
)}
|
||||
{!isLoading && items.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>درآمدی در این بازه ثبت نشده است</td></tr>
|
||||
)}
|
||||
{items.map(r => (
|
||||
<tr key={r.uuid}>
|
||||
<td>{r.doctor_name ?? '—'}</td>
|
||||
<td>{formatRial(r.gross_rials)}</td>
|
||||
<td>{formatRial(r.tax_rials)}</td>
|
||||
<td>{formatRial(r.sms_fee_rials)}</td>
|
||||
<td>{r.commission_percent}٪</td>
|
||||
<td>{formatRial(r.representation_share_rials)}</td>
|
||||
<td>{formatDate(r.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatRial, formatDate } from '../lib/utils';
|
||||
|
||||
interface WalletBalance { balance_rials: number }
|
||||
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
||||
interface SettlementRow {
|
||||
uuid: string;
|
||||
amount_rials: number;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'paid';
|
||||
admin_note: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'در انتظار بررسی', approved: 'تأیید شده', rejected: 'رد شده', paid: 'پرداخت شده',
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
pending: 'gray', approved: 'green', rejected: 'red', paid: 'green',
|
||||
};
|
||||
|
||||
export default function RepresentationSettlementPage() {
|
||||
const qc = useQueryClient();
|
||||
const [amount, setAmount] = useState('');
|
||||
|
||||
const balanceQ = useQuery({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get<ApiResponse<WalletBalance>>('/api/v1/wallet/balance'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const balance: number = ((balanceQ.data?.data as any)?.data ?? balanceQ.data?.data)?.balance_rials ?? 0;
|
||||
|
||||
const summaryQ = useQuery({
|
||||
queryKey: ['representation-summary'],
|
||||
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const income = useMemo<any>(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['settlements-mine'],
|
||||
queryFn: () => api.get<ApiResponse<SettlementRow[]>>('/api/v1/settlement'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const settlements: SettlementRow[] = (listQ.data?.data as any)?.data ?? listQ.data?.data ?? [];
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (amountRials: number) =>
|
||||
api.post<ApiResponse<SettlementRow>>('/api/v1/settlement', { amount_rials: amountRials }),
|
||||
onSuccess: () => {
|
||||
toast.success('درخواست تسویه ثبت شد');
|
||||
setAmount('');
|
||||
qc.invalidateQueries({ queryKey: ['settlements-mine'] });
|
||||
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['representation-summary'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
const n = Number(amount);
|
||||
if (!n || n <= 0) { toast.error('مبلغ نامعتبر است'); return; }
|
||||
if (n > balance) { toast.error('مبلغ بیشتر از موجودی قابل برداشت است'); return; }
|
||||
createMut.mutate(n);
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{ label: 'موجودی قابل برداشت', value: formatRial(balance), color: 'var(--primary)' },
|
||||
{ label: 'مجموع تسویهشده', value: formatRial(income?.settled_rials ?? 0), color: 'var(--success)' },
|
||||
{ label: 'در انتظار تسویه', value: formatRial(income?.pending_rials ?? 0), color: 'var(--warning)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">تسویه حساب</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
{cards.map(c => (
|
||||
<div key={c.label} className="stat" style={{ background: 'var(--surface-3)' }}>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val" style={{ color: c.color, fontSize: 15 }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16 }}>ثبت درخواست جدید</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به ریال"
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 240, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<button className="btn primary" onClick={submit} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
|
||||
</button>
|
||||
<span className="muted" style={{ fontSize: 12 }}>حداکثر: {formatRial(balance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16 }}>درخواستهای قبلی</h3>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="tbl" style={{ width: '100%' }}>
|
||||
<thead><tr><th>مبلغ</th><th>وضعیت</th><th>توضیح مدیر</th><th>تاریخ</th></tr></thead>
|
||||
<tbody>
|
||||
{settlements.length === 0 && (
|
||||
<tr><td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 16 }}>درخواستی ثبت نشده است</td></tr>
|
||||
)}
|
||||
{settlements.map(s => (
|
||||
<tr key={s.uuid}>
|
||||
<td>{formatRial(s.amount_rials)}</td>
|
||||
<td><span className={`badge ${STATUS_CLASS[s.status] ?? 'gray'}`}>{STATUS_LABEL[s.status] ?? s.status}</span></td>
|
||||
<td>{s.admin_note ?? '—'}</td>
|
||||
<td>{formatDate(s.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -450,6 +450,13 @@ body {
|
||||
.badge.violet { color: var(--violet); background: var(--violet-bg); }
|
||||
.badge.gray { color: var(--text-2); background: var(--surface-3); }
|
||||
|
||||
/* ── Simple data table (rep dashboard/finance/settlement) ────── */
|
||||
.tbl { border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid var(--border); }
|
||||
.tbl th { text-align: right; padding: 8px 12px; color: var(--text-3); font-weight: 500; white-space: nowrap; }
|
||||
.tbl td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── Appointment status badges ───────────────────────────────── */
|
||||
.appt-status {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
|
||||
@@ -151,6 +151,7 @@ Book an appointment slot.
|
||||
| `patient_gender` | string | ✅ | جنسیت بیمار — **همیشه الزامی**. ورودی `man`/`male` یا `woman`/`female` پذیرفته میشود و به فرمِ متعارف `man`/`woman` ذخیره میگردد |
|
||||
| `patient_reason` | string | ❌ | Reason for visit |
|
||||
| `note` | string | ❌ | Patient note |
|
||||
| `city_id` | integer | ❌ | شناسهی شهرِ دامنهی جاری (از `city.json` سایت). برای گاردِ پورسانت نماینده: اگر شهر نمایندهی فعال داشته باشد، `booking_representation_id` نوبت ست میشود. پورسانت فقط وقتی واریز میشود که این نماینده با نمایندهی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت |
|
||||
|
||||
> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامهی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره میکند. در پاسخ بهصورت `address_id` برمیگردد. همهی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست میکنند.
|
||||
|
||||
|
||||
@@ -471,3 +471,76 @@ Get yearly earnings dashboard for a representation.
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||||
|
||||
---
|
||||
|
||||
## داشبورد، عملکرد و مالیِ نمایندهی جاری
|
||||
|
||||
> همهی این endpointها `#[IsGranted('ROLE_REPRESENTATION')]` و scope بر اساس `#[CurrentUser]` (نه uuid مسیر). درآمد همیشه از `FinancialBreakdown.representation_share_rials` (پورسانت واقعیِ ثبتشده) محاسبه میشود، نه مبلغ کل نوبت. بازهها: امروز=`strtotime('today')`, هفته=۷ روز اخیر, ماه=۳۰ روز اخیر.
|
||||
|
||||
### GET `/api/v1/representation/dashboard/summary`
|
||||
|
||||
خلاصهی آمار نوبت و درآمد نمایندهی جاری.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"appointments": { "today": 0, "week": 3, "month": 12, "total": 40 },
|
||||
"income": {
|
||||
"today": 0, "week": 270000, "month": 909090, "total": 3000000,
|
||||
"settlable_rials": 2090910, "settled_rials": 500000, "pending_rials": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`settlable_rials` = موجودی کیفپول (`getWalletBalance`)؛ `settled_rials` = جمع Settlementهای `paid`؛ `pending_rials` = جمع `pending`+`approved`.
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||||
|
||||
### GET `/api/v1/representation/doctors/performance`
|
||||
|
||||
عملکرد پزشکانِ نمایندهی جاری (paginated). **Query:** `page`, `limit`.
|
||||
|
||||
**Response `200` (paginated):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...", "name": "دکتر ...",
|
||||
"appointments": { "today": 0, "week": 1, "month": 4, "total": 18 },
|
||||
"representation_income_rials": 363636,
|
||||
"subscription_status": "active"
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
`subscription_status`: `active` (اشتراک فعال دارد) یا `none`.
|
||||
|
||||
### GET `/api/v1/representation/finance/report`
|
||||
|
||||
گزارش مالی بازهای از ردیفهای `FinancialBreakdown` نمایندهی جاری (paginated). **Query:** `page`, `limit`, `from` (Unix ts), `to` (Unix ts).
|
||||
|
||||
**Response `200` (paginated):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...", "appointment_uuid": "...", "doctor_name": "دکتر ...",
|
||||
"gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000,
|
||||
"commission_percent": 20, "representation_share_rials": 90909,
|
||||
"created_at": "2026-06-24T..."
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
> **اصلاح `buildStats`** (در `GET /api/v1/representation/{uuid}/dashboard/monthly|yearly`): قبلاً آمار را به نماینده فیلتر نمیکرد (کلِ پلتفرم). اکنون `total_appointments` فقط نوبتهای پزشکانِ همان نماینده، `commission_rials` از `FinancialBreakdown.representation_share_rials`، و `total_revenue_rials` از `FinancialBreakdown.gross_rials` (source=appointment) همان نماینده محاسبه میشود.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260624123236 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE appointments ADD booking_representation_id INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE appointments DROP booking_representation_id');
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ class AppointmentController extends BaseController
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
@@ -259,6 +260,15 @@ class AppointmentController extends BaseController
|
||||
$appointment->setPatientGender($gender);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
// نمایندهی دامنهی جاری (city_id از سایت)؛ برای گاردِ پورسانت.
|
||||
$cityId = (int) ($data['city_id'] ?? 0);
|
||||
if ($cityId > 0) {
|
||||
$bookingRep = $this->representationRepo->findActiveByCityId($cityId);
|
||||
if ($bookingRep !== null) {
|
||||
$appointment->setBookingRepresentationId($bookingRep->getId());
|
||||
}
|
||||
}
|
||||
|
||||
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
||||
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) {
|
||||
|
||||
@@ -86,6 +86,9 @@ class Appointment
|
||||
#[ORM\Column(name: 'address_id', type: 'integer', nullable: true)]
|
||||
private ?int $addressId = null;
|
||||
|
||||
#[ORM\Column(name: 'booking_representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $bookingRepresentationId = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -119,8 +122,10 @@ class Appointment
|
||||
public function getPatientGender(): ?string { return $this->patientGender; }
|
||||
public function getPatientReason(): ?string { return $this->patientReason; }
|
||||
public function getAddressId(): ?int { return $this->addressId; }
|
||||
public function getBookingRepresentationId(): ?int { return $this->bookingRepresentationId; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
public function setBookingRepresentationId(?int $v): self { $this->bookingRepresentationId = $v; return $this; }
|
||||
public function setAddressId(?int $v): self { $this->addressId = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
|
||||
@@ -638,6 +638,7 @@ class PaymentController extends BaseController
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$appointment->getBookingRepresentationId(),
|
||||
$doctor->getId(),
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -30,6 +31,8 @@ class RepresentationActionController extends BaseController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
||||
private readonly \App\Subscription\Service\SubscriptionService $subscriptionService,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -451,4 +454,193 @@ class RepresentationActionController extends BaseController
|
||||
|
||||
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
|
||||
}
|
||||
|
||||
// ── Dashboard / Finance ───────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/dashboard/summary',
|
||||
summary: 'خلاصهی داشبورد نمایندهی جاری: آمار نوبت و درآمد (امروز/هفته/ماه/کل)',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'خلاصهی داشبورد')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/dashboard/summary', methods: ['GET'])]
|
||||
public function dashboardSummary(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$repId = $rep->getId();
|
||||
$now = time();
|
||||
$today = (int) strtotime('today');
|
||||
$week = $now - 7 * 86400;
|
||||
$month = $now - 30 * 86400;
|
||||
|
||||
$apptCount = fn(?int $start): int => (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.doctor d WHERE d.representationId = :repId' . ($start !== null ? ' AND a.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$income = fn(?int $start): int => (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId' . ($start !== null ? ' AND b.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
return $this->success([
|
||||
'appointments' => [
|
||||
'today' => $apptCount($today),
|
||||
'week' => $apptCount($week),
|
||||
'month' => $apptCount($month),
|
||||
'total' => $apptCount(null),
|
||||
],
|
||||
'income' => [
|
||||
'today' => $income($today),
|
||||
'week' => $income($week),
|
||||
'month' => $income($month),
|
||||
'total' => $income(null),
|
||||
'settlable_rials'=> $this->settlementRepo->getWalletBalance($user),
|
||||
'settled_rials' => $this->settlementRepo->sumByStatus($user, ['paid']),
|
||||
'pending_rials' => $this->settlementRepo->sumByStatus($user, ['pending', 'approved']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/doctors/performance',
|
||||
summary: 'عملکرد پزشکانِ نمایندهی جاری (نوبتها + درآمد + وضعیت اشتراک)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'عملکرد پزشکان')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/doctors/performance', methods: ['GET'])]
|
||||
public function doctorsPerformance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$repId = $rep->getId();
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('d.id, d.uuid, d.name')
|
||||
->from(Doctor::class, 'd')
|
||||
->where('d.representationId = :repId')
|
||||
->setParameter('repId', $repId)
|
||||
->orderBy('d.createdAt', 'DESC');
|
||||
|
||||
$total = (clone $qb)->select('COUNT(d.id)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$now = time();
|
||||
$today = (int) strtotime('today');
|
||||
$week = $now - 7 * 86400;
|
||||
$month = $now - 30 * 86400;
|
||||
|
||||
$apptCount = fn(int $doctorId, ?int $start): int => (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :d' . ($start !== null ? ' AND a.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['d' => $doctorId, 'start' => $start] : ['d' => $doctorId])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$items = array_map(function (array $d) use ($apptCount, $today, $week, $month): array {
|
||||
$doctorId = (int) $d['id'];
|
||||
$income = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.doctorId = :d'
|
||||
)->setParameter('d', $doctorId)->getSingleScalarResult() ?? 0);
|
||||
|
||||
$sub = $this->subscriptionService->getActiveSubscription('doctor', $doctorId);
|
||||
$status = $sub === null ? 'none' : 'active';
|
||||
|
||||
return [
|
||||
'uuid' => $d['uuid'],
|
||||
'name' => $d['name'],
|
||||
'appointments' => [
|
||||
'today' => $apptCount($doctorId, $today),
|
||||
'week' => $apptCount($doctorId, $week),
|
||||
'month' => $apptCount($doctorId, $month),
|
||||
'total' => $apptCount($doctorId, null),
|
||||
],
|
||||
'representation_income_rials' => $income,
|
||||
'subscription_status' => $status,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/finance/report',
|
||||
summary: 'گزارش مالی نمایندهی جاری بر اساس بازه (ردیفهای FinancialBreakdown)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'گزارش مالی نماینده')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/finance/report', methods: ['GET'])]
|
||||
public function financeReport(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'b.uuid, b.grossRials, b.taxRials, b.smsFeeRials, b.commissionPercent,
|
||||
b.representationShareRials, b.createdAt,
|
||||
a.uuid as appointment_uuid, doc.name as doctor_name'
|
||||
)
|
||||
->from(FinancialBreakdown::class, 'b')
|
||||
->join('b.payment', 'p')
|
||||
->leftJoin('p.appointment', 'a')
|
||||
->leftJoin('a.doctor', 'doc')
|
||||
->where('b.representationId = :repId')
|
||||
->setParameter('repId', $rep->getId())
|
||||
->orderBy('b.createdAt', 'DESC');
|
||||
|
||||
if ($from !== null && $from !== '') {
|
||||
$qb->andWhere('b.createdAt >= :from')->setParameter('from', (int) $from);
|
||||
}
|
||||
if ($to !== null && $to !== '') {
|
||||
$qb->andWhere('b.createdAt <= :to')->setParameter('to', (int) $to);
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(b.uuid)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $b) => [
|
||||
'uuid' => $b['uuid'],
|
||||
'appointment_uuid' => $b['appointment_uuid'] ?? null,
|
||||
'doctor_name' => $b['doctor_name'] ?? null,
|
||||
'gross_rials' => (int) $b['grossRials'],
|
||||
'tax_rials' => (int) $b['taxRials'],
|
||||
'sms_fee_rials' => (int) $b['smsFeeRials'],
|
||||
'commission_percent' => (float) $b['commissionPercent'],
|
||||
'representation_share_rials' => (int) $b['representationShareRials'],
|
||||
'created_at' => date('c', (int) $b['createdAt']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ class RepresentationController extends BaseController
|
||||
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear, 'jalali_month' => $jMonth],
|
||||
'stats' => $this->buildStats($startTs, $endTs),
|
||||
'stats' => $this->buildStats($rep, $startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ class RepresentationController extends BaseController
|
||||
[$mStart, $mEnd] = $this->jalali->jalaliMonthRange($jYear, $m);
|
||||
$months[] = [
|
||||
'jalali_month' => $m,
|
||||
'stats' => $this->buildStats($mStart, $mEnd),
|
||||
'stats' => $this->buildStats($rep, $mStart, $mEnd),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -383,35 +383,42 @@ class RepresentationController extends BaseController
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear],
|
||||
'months' => $months,
|
||||
'totals' => $this->buildStats($startTs, $endTs),
|
||||
'totals' => $this->buildStats($rep, $startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildStats(int $startTs, int $endTs): array
|
||||
private function buildStats(Representation $rep, 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();
|
||||
|
||||
$totalRevenue = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(p.amountRials) 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() ?? 0);
|
||||
$repId = $rep->getId();
|
||||
|
||||
// نوبتهای پزشکانِ همین نماینده در بازه.
|
||||
$totalAppointments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['start' => $startTs, 'end' => $endTs])->getSingleScalarResult();
|
||||
JOIN a.doctor d
|
||||
WHERE d.representationId = :repId AND a.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult();
|
||||
|
||||
// درآمد واقعیِ ثبتشده برای نماینده (سهم نماینده از FinancialBreakdown)، نه مبلغ کل نوبت.
|
||||
$commission = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId AND b.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
// مبلغ کلِ نوبتهای مشمول پورسانتِ همین نماینده در بازه (برای اطلاع).
|
||||
$totalRevenue = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.grossRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId AND b.source = :src AND b.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'src' => 'appointment', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
return [
|
||||
'total_payments' => $totalPayments,
|
||||
'total_revenue_rials' => $totalRevenue,
|
||||
'total_appointments' => $totalAppointments,
|
||||
'total_revenue_rials' => $totalRevenue,
|
||||
'commission_rials' => $commission,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ class RepresentationRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findActiveByCityId(int $cityId): ?Representation
|
||||
{
|
||||
return $this->findOneBy(['cityId' => $cityId, 'active' => true]);
|
||||
}
|
||||
|
||||
public function save(Representation $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -43,6 +43,16 @@ class SettlementRepository extends ServiceEntityRepository
|
||||
return $credit - $debit;
|
||||
}
|
||||
|
||||
/** @param string[] $statuses */
|
||||
public function sumByStatus(User $user, array $statuses): int
|
||||
{
|
||||
return (int) ($this->getEntityManager()->createQuery(
|
||||
'SELECT SUM(s.amountRials) FROM App\Settlement\Entity\Settlement s
|
||||
WHERE s.user = :user AND s.status IN (:statuses)'
|
||||
)->setParameters(['user' => $user, 'statuses' => $statuses])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
}
|
||||
|
||||
public function save(Settlement $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -28,12 +28,18 @@ class CommissionService
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** پورسانت نوبت: درصد = commission_percent همان نماینده. */
|
||||
public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
|
||||
/**
|
||||
* پورسانت نوبت: درصد = commission_percent همان نماینده.
|
||||
* گاردِ دامنه: فقط وقتی که پزشک متعلق به نماینده باشد و نوبت هم از دامنهی همان نماینده ثبت شده باشد.
|
||||
*/
|
||||
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
|
||||
{
|
||||
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
|
||||
|
||||
$rep = $this->resolveRep($representationId);
|
||||
// هر دو شرط لازم است و باید یکی باشند.
|
||||
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
|
||||
|
||||
$rep = $this->resolveRep($doctorRepId);
|
||||
if ($rep === null) return;
|
||||
|
||||
$this->settle(
|
||||
|
||||
Reference in New Issue
Block a user