feat: implement domain guard for commission calculation and enhance representation dashboard

- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor.
- Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative.
- Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports.
- Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests.
- Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
This commit is contained in:
hamed
2026-06-24 16:14:41 +03:30
parent 89e4a424f8
commit 9603b702c1
18 changed files with 928 additions and 54 deletions
@@ -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);