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
@@ -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) {
+5
View File
@@ -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);
+9 -3
View File
@@ -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(