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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user