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:
@@ -32,6 +32,7 @@ class AppointmentController extends BaseController
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
@@ -259,6 +260,15 @@ class AppointmentController extends BaseController
|
||||
$appointment->setPatientGender($gender);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
// نمایندهی دامنهی جاری (city_id از سایت)؛ برای گاردِ پورسانت.
|
||||
$cityId = (int) ($data['city_id'] ?? 0);
|
||||
if ($cityId > 0) {
|
||||
$bookingRep = $this->representationRepo->findActiveByCityId($cityId);
|
||||
if ($bookingRep !== null) {
|
||||
$appointment->setBookingRepresentationId($bookingRep->getId());
|
||||
}
|
||||
}
|
||||
|
||||
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
||||
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) {
|
||||
|
||||
@@ -86,6 +86,9 @@ class Appointment
|
||||
#[ORM\Column(name: 'address_id', type: 'integer', nullable: true)]
|
||||
private ?int $addressId = null;
|
||||
|
||||
#[ORM\Column(name: 'booking_representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $bookingRepresentationId = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -119,8 +122,10 @@ class Appointment
|
||||
public function getPatientGender(): ?string { return $this->patientGender; }
|
||||
public function getPatientReason(): ?string { return $this->patientReason; }
|
||||
public function getAddressId(): ?int { return $this->addressId; }
|
||||
public function getBookingRepresentationId(): ?int { return $this->bookingRepresentationId; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
public function setBookingRepresentationId(?int $v): self { $this->bookingRepresentationId = $v; return $this; }
|
||||
public function setAddressId(?int $v): self { $this->addressId = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
|
||||
@@ -638,6 +638,7 @@ class PaymentController extends BaseController
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$appointment->getBookingRepresentationId(),
|
||||
$doctor->getId(),
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -30,6 +31,8 @@ class RepresentationActionController extends BaseController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
||||
private readonly \App\Subscription\Service\SubscriptionService $subscriptionService,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -451,4 +454,193 @@ class RepresentationActionController extends BaseController
|
||||
|
||||
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
|
||||
}
|
||||
|
||||
// ── Dashboard / Finance ───────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/dashboard/summary',
|
||||
summary: 'خلاصهی داشبورد نمایندهی جاری: آمار نوبت و درآمد (امروز/هفته/ماه/کل)',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'خلاصهی داشبورد')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/dashboard/summary', methods: ['GET'])]
|
||||
public function dashboardSummary(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$repId = $rep->getId();
|
||||
$now = time();
|
||||
$today = (int) strtotime('today');
|
||||
$week = $now - 7 * 86400;
|
||||
$month = $now - 30 * 86400;
|
||||
|
||||
$apptCount = fn(?int $start): int => (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.doctor d WHERE d.representationId = :repId' . ($start !== null ? ' AND a.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$income = fn(?int $start): int => (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId' . ($start !== null ? ' AND b.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
return $this->success([
|
||||
'appointments' => [
|
||||
'today' => $apptCount($today),
|
||||
'week' => $apptCount($week),
|
||||
'month' => $apptCount($month),
|
||||
'total' => $apptCount(null),
|
||||
],
|
||||
'income' => [
|
||||
'today' => $income($today),
|
||||
'week' => $income($week),
|
||||
'month' => $income($month),
|
||||
'total' => $income(null),
|
||||
'settlable_rials'=> $this->settlementRepo->getWalletBalance($user),
|
||||
'settled_rials' => $this->settlementRepo->sumByStatus($user, ['paid']),
|
||||
'pending_rials' => $this->settlementRepo->sumByStatus($user, ['pending', 'approved']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/doctors/performance',
|
||||
summary: 'عملکرد پزشکانِ نمایندهی جاری (نوبتها + درآمد + وضعیت اشتراک)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'عملکرد پزشکان')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/doctors/performance', methods: ['GET'])]
|
||||
public function doctorsPerformance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$repId = $rep->getId();
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('d.id, d.uuid, d.name')
|
||||
->from(Doctor::class, 'd')
|
||||
->where('d.representationId = :repId')
|
||||
->setParameter('repId', $repId)
|
||||
->orderBy('d.createdAt', 'DESC');
|
||||
|
||||
$total = (clone $qb)->select('COUNT(d.id)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$now = time();
|
||||
$today = (int) strtotime('today');
|
||||
$week = $now - 7 * 86400;
|
||||
$month = $now - 30 * 86400;
|
||||
|
||||
$apptCount = fn(int $doctorId, ?int $start): int => (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :d' . ($start !== null ? ' AND a.createdAt >= :start' : '')
|
||||
)->setParameters($start !== null ? ['d' => $doctorId, 'start' => $start] : ['d' => $doctorId])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$items = array_map(function (array $d) use ($apptCount, $today, $week, $month): array {
|
||||
$doctorId = (int) $d['id'];
|
||||
$income = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.doctorId = :d'
|
||||
)->setParameter('d', $doctorId)->getSingleScalarResult() ?? 0);
|
||||
|
||||
$sub = $this->subscriptionService->getActiveSubscription('doctor', $doctorId);
|
||||
$status = $sub === null ? 'none' : 'active';
|
||||
|
||||
return [
|
||||
'uuid' => $d['uuid'],
|
||||
'name' => $d['name'],
|
||||
'appointments' => [
|
||||
'today' => $apptCount($doctorId, $today),
|
||||
'week' => $apptCount($doctorId, $week),
|
||||
'month' => $apptCount($doctorId, $month),
|
||||
'total' => $apptCount($doctorId, null),
|
||||
],
|
||||
'representation_income_rials' => $income,
|
||||
'subscription_status' => $status,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/finance/report',
|
||||
summary: 'گزارش مالی نمایندهی جاری بر اساس بازه (ردیفهای FinancialBreakdown)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'گزارش مالی نماینده')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/finance/report', methods: ['GET'])]
|
||||
public function financeReport(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'b.uuid, b.grossRials, b.taxRials, b.smsFeeRials, b.commissionPercent,
|
||||
b.representationShareRials, b.createdAt,
|
||||
a.uuid as appointment_uuid, doc.name as doctor_name'
|
||||
)
|
||||
->from(FinancialBreakdown::class, 'b')
|
||||
->join('b.payment', 'p')
|
||||
->leftJoin('p.appointment', 'a')
|
||||
->leftJoin('a.doctor', 'doc')
|
||||
->where('b.representationId = :repId')
|
||||
->setParameter('repId', $rep->getId())
|
||||
->orderBy('b.createdAt', 'DESC');
|
||||
|
||||
if ($from !== null && $from !== '') {
|
||||
$qb->andWhere('b.createdAt >= :from')->setParameter('from', (int) $from);
|
||||
}
|
||||
if ($to !== null && $to !== '') {
|
||||
$qb->andWhere('b.createdAt <= :to')->setParameter('to', (int) $to);
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(b.uuid)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $b) => [
|
||||
'uuid' => $b['uuid'],
|
||||
'appointment_uuid' => $b['appointment_uuid'] ?? null,
|
||||
'doctor_name' => $b['doctor_name'] ?? null,
|
||||
'gross_rials' => (int) $b['grossRials'],
|
||||
'tax_rials' => (int) $b['taxRials'],
|
||||
'sms_fee_rials' => (int) $b['smsFeeRials'],
|
||||
'commission_percent' => (float) $b['commissionPercent'],
|
||||
'representation_share_rials' => (int) $b['representationShareRials'],
|
||||
'created_at' => date('c', (int) $b['createdAt']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ class RepresentationController extends BaseController
|
||||
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear, 'jalali_month' => $jMonth],
|
||||
'stats' => $this->buildStats($startTs, $endTs),
|
||||
'stats' => $this->buildStats($rep, $startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ class RepresentationController extends BaseController
|
||||
[$mStart, $mEnd] = $this->jalali->jalaliMonthRange($jYear, $m);
|
||||
$months[] = [
|
||||
'jalali_month' => $m,
|
||||
'stats' => $this->buildStats($mStart, $mEnd),
|
||||
'stats' => $this->buildStats($rep, $mStart, $mEnd),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -383,35 +383,42 @@ class RepresentationController extends BaseController
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear],
|
||||
'months' => $months,
|
||||
'totals' => $this->buildStats($startTs, $endTs),
|
||||
'totals' => $this->buildStats($rep, $startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildStats(int $startTs, int $endTs): array
|
||||
private function buildStats(Representation $rep, int $startTs, int $endTs): array
|
||||
{
|
||||
$totalPayments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(p.id) FROM App\Payment\Entity\Payment p
|
||||
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$totalRevenue = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(p.amountRials) FROM App\Payment\Entity\Payment p
|
||||
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
$repId = $rep->getId();
|
||||
|
||||
// نوبتهای پزشکانِ همین نماینده در بازه.
|
||||
$totalAppointments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['start' => $startTs, 'end' => $endTs])->getSingleScalarResult();
|
||||
JOIN a.doctor d
|
||||
WHERE d.representationId = :repId AND a.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult();
|
||||
|
||||
// درآمد واقعیِ ثبتشده برای نماینده (سهم نماینده از FinancialBreakdown)، نه مبلغ کل نوبت.
|
||||
$commission = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId AND b.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
// مبلغ کلِ نوبتهای مشمول پورسانتِ همین نماینده در بازه (برای اطلاع).
|
||||
$totalRevenue = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(b.grossRials) FROM App\Settlement\Entity\FinancialBreakdown b
|
||||
WHERE b.representationId = :repId AND b.source = :src AND b.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['repId' => $repId, 'src' => 'appointment', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
return [
|
||||
'total_payments' => $totalPayments,
|
||||
'total_revenue_rials' => $totalRevenue,
|
||||
'total_appointments' => $totalAppointments,
|
||||
'total_revenue_rials' => $totalRevenue,
|
||||
'commission_rials' => $commission,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ class RepresentationRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findActiveByCityId(int $cityId): ?Representation
|
||||
{
|
||||
return $this->findOneBy(['cityId' => $cityId, 'active' => true]);
|
||||
}
|
||||
|
||||
public function save(Representation $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -43,6 +43,16 @@ class SettlementRepository extends ServiceEntityRepository
|
||||
return $credit - $debit;
|
||||
}
|
||||
|
||||
/** @param string[] $statuses */
|
||||
public function sumByStatus(User $user, array $statuses): int
|
||||
{
|
||||
return (int) ($this->getEntityManager()->createQuery(
|
||||
'SELECT SUM(s.amountRials) FROM App\Settlement\Entity\Settlement s
|
||||
WHERE s.user = :user AND s.status IN (:statuses)'
|
||||
)->setParameters(['user' => $user, 'statuses' => $statuses])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
}
|
||||
|
||||
public function save(Settlement $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -28,12 +28,18 @@ class CommissionService
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** پورسانت نوبت: درصد = commission_percent همان نماینده. */
|
||||
public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
|
||||
/**
|
||||
* پورسانت نوبت: درصد = commission_percent همان نماینده.
|
||||
* گاردِ دامنه: فقط وقتی که پزشک متعلق به نماینده باشد و نوبت هم از دامنهی همان نماینده ثبت شده باشد.
|
||||
*/
|
||||
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
|
||||
{
|
||||
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
|
||||
|
||||
$rep = $this->resolveRep($representationId);
|
||||
// هر دو شرط لازم است و باید یکی باشند.
|
||||
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
|
||||
|
||||
$rep = $this->resolveRep($doctorRepId);
|
||||
if ($rep === null) return;
|
||||
|
||||
$this->settle(
|
||||
|
||||
Reference in New Issue
Block a user