diff --git a/.claude/prompt/representation-domain-guard-dashboard-settlement.md b/.claude/prompt/representation-domain-guard-dashboard-settlement.md new file mode 100644 index 00000000..a49970a9 --- /dev/null +++ b/.claude/prompt/representation-domain-guard-dashboard-settlement.md @@ -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` معنا دارد. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 5f7d82ca..218ef479 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> {/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */} + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index a0c1681a..d29b858c 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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: "تسویه حساب" }, + ], + }, ]; } diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 6bc250c5..905bc489 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -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>('/api/v1/representation/me'), + queryFn: () => api.get>('/api/v1/representation/me'), staleTime: 300_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rep = useMemo(() => (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>( - `/api/v1/representation/${repUuid}/dashboard/monthly?year=${jYear}&month=${jMonth}`, - ), - enabled: !!repUuid, + const summaryQ = useQuery({ + queryKey: ['representation-summary'], + queryFn: () => api.get>('/api/v1/representation/dashboard/summary'), staleTime: 120_000, }); - const yearlyQ = useQuery({ - queryKey: ['representation-yearly', repUuid, jYear], - queryFn: () => api.get>( - `/api/v1/representation/${repUuid}/dashboard/yearly?year=${jYear}`, - ), - enabled: !!repUuid, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const summary = useMemo(() => (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data, [summaryQ.data]); + + const perfQ = useQuery({ + queryKey: ['representation-doctors-performance'], + queryFn: () => api.get>('/api/v1/representation/doctors/performance?limit=100'), staleTime: 120_000, }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const monthly = useMemo(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const yearly = useMemo(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]); + const doctors: RepDoctorPerf[] = perfQ.data?.data ?? []; if (meQ.isLoading) return ; 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 = { active: 'فعال', expired: 'منقضی', none: 'بدون اشتراک' }; return (
@@ -1032,17 +1047,17 @@ function RepresentationDashboard() {

داشبورد نماینده

{today} · {rep?.full_name ?? ''}
- -
- {cards.map(c => ( +
+ {apptCards.map(c => (
- +
{c.label}
{c.value}
@@ -1050,6 +1065,56 @@ function RepresentationDashboard() { ))}
+
+
+

درآمد نماینده

+
+
+ {incomeCards.map(c => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+
+ +
+
+

عملکرد پزشکان

+
+
+ + + + + + + + + {doctors.length === 0 && ( + + )} + {doctors.map(d => ( + + + + + + + + + + ))} + +
پزشکامروزهفتهماهکلدرآمد نمایندهاشتراک
پزشکی یافت نشد
{d.name}{formatNumber(d.appointments.today)}{formatNumber(d.appointments.week)}{formatNumber(d.appointments.month)}{formatNumber(d.appointments.total)}{formatRial(d.representation_income_rials)} + + {subLabel[d.subscription_status] ?? d.subscription_status} + +
+
+
+

دسترسی سریع

@@ -1058,6 +1123,8 @@ function RepresentationDashboard() { پزشکان من کلینیک‌ها نوبت‌ها + تسویه حساب + گزارش مالی
diff --git a/assets/admin/pages/RepresentationFinancePage.tsx b/assets/admin/pages/RepresentationFinancePage.tsx new file mode 100644 index 00000000..0987e46d --- /dev/null +++ b/assets/admin/pages/RepresentationFinancePage.tsx @@ -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 = { + today: 'امروز', week: 'این هفته', month: 'این ماه', all: 'همه', +}; + +export default function RepresentationFinancePage() { + const [range, setRange] = useState('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>(`/api/v1/representation/finance/report?${params}`); + }, + }); + + const items: FinanceRow[] = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + return ( +
+
+
+

گزارش مالی

+
درآمد ثبت‌شده از پورسانت نوبت‌ها
+
+
+ +
+ {(['today', 'week', 'month', 'all'] as RangeKey[]).map(k => ( + + ))} +
+ +
+
+ + + + + + + + + {isLoading && ( + + )} + {!isLoading && items.length === 0 && ( + + )} + {items.map(r => ( + + + + + + + + + + ))} + +
پزشکمبلغ نوبتمالیاتپیامکدرصدسهم نمایندهتاریخ
در حال بارگذاری...
درآمدی در این بازه ثبت نشده است
{r.doctor_name ?? '—'}{formatRial(r.gross_rials)}{formatRial(r.tax_rials)}{formatRial(r.sms_fee_rials)}{r.commission_percent}٪{formatRial(r.representation_share_rials)}{formatDate(r.created_at)}
+
+
+ + +
+ ); +} diff --git a/assets/admin/pages/RepresentationSettlementPage.tsx b/assets/admin/pages/RepresentationSettlementPage.tsx new file mode 100644 index 00000000..311c7b6b --- /dev/null +++ b/assets/admin/pages/RepresentationSettlementPage.tsx @@ -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 = { + pending: 'در انتظار بررسی', approved: 'تأیید شده', rejected: 'رد شده', paid: 'پرداخت شده', +}; +const STATUS_CLASS: Record = { + 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>('/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>('/api/v1/representation/dashboard/summary'), + staleTime: 60_000, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const income = useMemo(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]); + + const listQ = useQuery({ + queryKey: ['settlements-mine'], + queryFn: () => api.get>('/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>('/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 ( +
+
+
+

تسویه حساب

+
درخواست برداشت از کیف‌پول نماینده
+
+
+ +
+ {cards.map(c => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ +
+
+

ثبت درخواست جدید

+
+
+ 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' }} + /> + + حداکثر: {formatRial(balance)} +
+
+ +
+
+

درخواست‌های قبلی

+
+
+ + + + {settlements.length === 0 && ( + + )} + {settlements.map(s => ( + + + + + + + ))} + +
مبلغوضعیتتوضیح مدیرتاریخ
درخواستی ثبت نشده است
{formatRial(s.amount_rials)}{STATUS_LABEL[s.status] ?? s.status}{s.admin_note ?? '—'}{formatDate(s.created_at)}
+
+
+
+ ); +} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index e83d8c01..e746175c 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -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; diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 479b72ac..71b2a0af 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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`، ادمین) آدرس را به همین شکل ست می‌کنند. diff --git a/docs/api/representation.md b/docs/api/representation.md index 622895d4..705fb61d 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -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) همان نماینده محاسبه می‌شود. diff --git a/migrations/Version20260624123236.php b/migrations/Version20260624123236.php new file mode 100644 index 00000000..f2fd0dc6 --- /dev/null +++ b/migrations/Version20260624123236.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index c28d86a8..f7581dc7 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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) { diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 473037df..a83f3dd6 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -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; } diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 5be9b4c1..cc321a02 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -638,6 +638,7 @@ class PaymentController extends BaseController $this->commissionService->processAppointment( $payment, $doctor->getRepresentationId(), + $appointment->getBookingRepresentationId(), $doctor->getId(), ); diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index fe1168ad..bf7cb391 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -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); + } } diff --git a/src/Representation/Controller/RepresentationController.php b/src/Representation/Controller/RepresentationController.php index 0a2c8e63..15d659cf 100644 --- a/src/Representation/Controller/RepresentationController.php +++ b/src/Representation/Controller/RepresentationController.php @@ -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, ]; } } diff --git a/src/Representation/Repository/RepresentationRepository.php b/src/Representation/Repository/RepresentationRepository.php index dbacced1..60961dbc 100644 --- a/src/Representation/Repository/RepresentationRepository.php +++ b/src/Representation/Repository/RepresentationRepository.php @@ -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); diff --git a/src/Settlement/Repository/SettlementRepository.php b/src/Settlement/Repository/SettlementRepository.php index adcdd8f7..01e6e4a0 100644 --- a/src/Settlement/Repository/SettlementRepository.php +++ b/src/Settlement/Repository/SettlementRepository.php @@ -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); diff --git a/src/Settlement/Service/CommissionService.php b/src/Settlement/Service/CommissionService.php index 4bb85b18..f683eeda 100644 --- a/src/Settlement/Service/CommissionService.php +++ b/src/Settlement/Service/CommissionService.php @@ -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(