clinicRepo->findByUser($user); if ($clinic === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404); } $clinicId = $clinic->getId(); $todayStart = strtotime('today midnight'); $todayEnd = strtotime('tomorrow midnight') - 1; $monthStart = strtotime('first day of this month midnight'); $from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart; $to = $request->query->get('to') ? (int) $request->query->get('to') : time(); // آمار نوبت‌های امروز و این ماه $stats = $this->em->createQuery(' SELECT COUNT(a.id) AS today_appointments, SUM(CASE WHEN a.slotStart >= :monthStart THEN 1 ELSE 0 END) AS this_month_appointments FROM App\Appointment\Entity\Appointment a JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors WHERE c.id = :clinicId AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd ')->setParameters([ 'clinicId' => $clinicId, 'todayStart' => $todayStart, 'todayEnd' => $todayEnd, 'monthStart' => $monthStart, ])->getOneOrNullResult() ?? []; // شمارش کل نوبت‌های این ماه (query جداگانه) $monthCount = (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors WHERE c.id = :clinicId AND a.slotStart >= :monthStart ')->setParameters([ 'clinicId' => $clinicId, 'monthStart' => $monthStart, ])->getSingleScalarResult(); // تعداد دعوتنامه‌های در انتظار $pendingInvitations = (int) $this->em->createQuery(' SELECT COUNT(i.id) FROM App\ClinicInvitation\Entity\ClinicDoctorInvitation i WHERE i.clinic = :clinic AND i.status = :status ')->setParameters([ 'clinic' => $clinic, 'status' => 'pending', ])->getSingleScalarResult(); // ۵ نوبت امروز این کلینیک $todayAppts = $this->em->createQuery(' SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, d.name AS doctor_name, si.name AS service_name, a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status FROM App\Appointment\Entity\Appointment a JOIN a.doctor d JOIN a.user u LEFT JOIN a.serviceItem si JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors WHERE c.id = :clinicId AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd ORDER BY a.slotStart ASC ')->setMaxResults(5)->setParameters([ 'clinicId' => $clinicId, 'todayStart' => $todayStart, 'todayEnd' => $todayEnd, ])->getArrayResult(); // لیست پزشکان با شمارش نوبت امروز $doctors = $this->em->createQuery(' SELECT d.uuid, d.name, COUNT(a.id) AS today_count FROM App\Doctor\Entity\Doctor d JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors LEFT JOIN App\Appointment\Entity\Appointment a WITH a.doctor = d AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd WHERE c.id = :clinicId GROUP BY d.id ')->setParameters([ 'clinicId' => $clinicId, 'todayStart' => $todayStart, 'todayEnd' => $todayEnd, ])->getArrayResult(); $smsBalance = $this->smsWalletService->getBalance('clinic', $clinicId); $uniquePatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, $from, $to); $revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to); $totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time()); $rev = $this->revenueDaily('clinic', $clinicId); $apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($clinicId): int { return (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e ')->setParameters(['clinicId' => $clinicId, 's' => $ds, 'e' => $de])->getSingleScalarResult(); }); return $this->success([ 'clinic' => [ 'uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'is_active' => $clinic->isActive(), 'logo' => $clinic->getClinicLogo(), ], 'stats' => [ 'total_doctors' => count($doctors), 'today_appointments' => (int) ($stats['today_appointments'] ?? 0), 'this_month_appointments' => $monthCount, 'pending_invitations' => $pendingInvitations, 'sms_wallet_balance' => $smsBalance, 'unique_patients_count' => $uniquePatients, 'total_patients' => $totalPatients, 'revenue_period_rials' => $revenuePeriod, 'today_payments_rials' => $rev['today_payments_rials'], 'week_payments_rials' => $rev['week_payments_rials'], ], 'charts' => [ 'revenue_by_day' => $rev['revenue'], 'appointments_by_day' => $apptByDay, ], 'period' => ['from' => $from, 'to' => $to], 'today_appointments' => $todayAppts, 'doctors' => $doctors, ]); } // ── Doctor Dashboard ───────────────────────────────────────────────────── #[Route('/api/v1/dashboard/doctor', methods: ['GET'])] #[IsGranted('ROLE_DOCTOR')] public function doctor(Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUser($user); if ($doctor === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } // محیط فعال تعیین می‌کند این داشبورد شخصی است یا داخل یک کلینیک. بدون این، // پزشکِ دعوت‌شده در محیط کلینیک آمار و درآمد مطب شخصی خودش را می‌دید. $context = $this->contextResolver->tryResolve($user, $request->query->get('clinic_uuid')); $clinic = $context?->clinic; $doctorId = $doctor->getId(); $todayStart = strtotime('today midnight'); $todayEnd = strtotime('tomorrow midnight') - 1; $tmrStart = strtotime('tomorrow midnight'); $tmrEnd = strtotime('tomorrow midnight') + 86399; $monthStart = strtotime('first day of this month midnight'); $from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart; $to = $request->query->get('to') ? (int) $request->query->get('to') : time(); // آمار $todayCount = $this->countAppointments($doctor, $clinic, $todayStart, $todayEnd); $tmrCount = $this->countAppointments($doctor, $clinic, $tmrStart, $tmrEnd); $monthCount = $this->countAppointments($doctor, $clinic, $monthStart, PHP_INT_MAX); // میانگین و تعداد امتیاز $ratingRow = $this->em->createQuery(' SELECT AVG((r.waitingTimeAtClinic + r.accuracyOfDiagnosis + r.doctorBehavior + r.clinicCleanliness + r.doctorExpertise) / 5.0) AS avg_score, COUNT(r.id) AS total FROM App\Rating\Entity\Rate r WHERE r.doctor = :doctor ')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? []; // نوبت‌های امروز $todayApptsQb = $this->em->createQueryBuilder() ->select('a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, d.name AS doctor_name, si.name AS service_name, a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status') ->from(\App\Appointment\Entity\Appointment::class, 'a') ->join('a.user', 'u') ->join('a.doctor', 'd') ->leftJoin('a.serviceItem', 'si') ->where('a.doctor = :doctor') ->andWhere('a.slotStart >= :s AND a.slotStart <= :e') ->setParameter('doctor', $doctor) ->setParameter('s', $todayStart) ->setParameter('e', $todayEnd) ->orderBy('a.slotStart', 'ASC') ->setMaxResults(10); $this->restrictToClinicAddresses($todayApptsQb, $clinic); $todayAppts = $todayApptsQb->getQuery()->getArrayResult(); // کلینیک‌های عضو $clinics = $this->em->createQuery(' SELECT c.uuid, c.name, c.clinicLogo AS logo FROM App\Clinic\Entity\Clinic c JOIN c.doctors d WHERE d.id = :doctorId ')->setParameter('doctorId', $doctorId)->getArrayResult(); $apptByDay = $this->appointmentsDaily( fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de) ); $stats = [ 'today_appointments' => $todayCount, 'tomorrow_appointments' => $tmrCount, 'this_month_appointments' => $monthCount, 'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null, 'total_ratings' => (int) ($ratingRow['total'] ?? 0), ]; $charts = ['appointments_by_day' => $apptByDay]; // ارقام مالی و کیف پول پیامک به مطب شخصی تعلق دارند. در محیط کلینیک اصلاً // برگردانده نمی‌شوند مگر کاربر مالک همان کلینیک باشد — مخفی‌کردن در UI کافی // نیست، چون endpoint مستقیماً قابل صدا زدن است. if ($this->maySeeFinancials($user, $clinic)) { $rev = $this->revenueDaily('doctor', $doctorId); $stats['sms_wallet_balance'] = $this->smsWalletService->getBalance('doctor', $doctorId); $stats['unique_patients_count'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to); $stats['total_patients'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time()); $stats['revenue_period_rials'] = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to); $stats['today_payments_rials'] = $rev['today_payments_rials']; $stats['week_payments_rials'] = $rev['week_payments_rials']; $charts['revenue_by_day'] = $rev['revenue']; } return $this->success([ 'doctor' => [ 'uuid' => $doctor->getUuid(), 'name' => $doctor->getName(), 'degree' => $doctor->getDegree(), ], 'context' => [ 'type' => $clinic === null ? 'personal' : 'clinic', 'clinic_uuid' => $clinic?->getUuid(), 'clinic_name' => $clinic?->getName(), ], 'stats' => $stats, 'charts' => $charts, 'period' => ['from' => $from, 'to' => $to], 'today_appointments' => $todayAppts, // فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد. 'clinics' => $clinic === null ? $clinics : [], ]); } /** * نوبت‌های پزشک در یک بازه، محدود به محیط جاری. در محیط کلینیک فقط نوبت‌هایی * شمرده می‌شوند که آدرسشان متعلق به همان کلینیک است. */ private function countAppointments(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): int { $qb = $this->em->createQueryBuilder() ->select('COUNT(a.id)') ->from(\App\Appointment\Entity\Appointment::class, 'a') ->where('a.doctor = :doctor') ->andWhere('a.slotStart >= :s AND a.slotStart <= :e') ->setParameter('doctor', $doctor) ->setParameter('s', $from) ->setParameter('e', $to); $this->restrictToClinicAddresses($qb, $clinic); return (int) $qb->getQuery()->getSingleScalarResult(); } private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void { if ($clinic === null) { return; } $addressIds = array_map( fn(\App\Doctor\Entity\DoctorAddress $a): int => (int) $a->getId(), $this->addressRepo->findForContext($qb->getParameter('doctor')->getValue(), $clinic->getId()) ); $qb->andWhere('a.addressId IN (:addressIds)') ->setParameter('addressIds', $addressIds ?: [0]); } /** فقط محیط شخصی خود پزشک، یا مالک همان کلینیک. */ private function maySeeFinancials(User $user, ?\App\Clinic\Entity\Clinic $clinic): bool { if ($clinic === null) { return true; } return $clinic->getUser()->getId() === $user->getId() && $this->permChecker->can($user, $clinic, 'payments', 'view'); } /** * سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته. * @return array{revenue: array, today_payments_rials:int, week_payments_rials:int} */ private function revenueDaily(string $entityType, int $entityId): array { $fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM'); $series = []; $todayPay = 0; $weekPay = 0; for ($i = 6; $i >= 0; $i--) { $ds = strtotime('today midnight') - $i * 86400; $de = $ds + 86399; $rev = (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $ds, $de); $series[] = ['label' => $fmt->format($ds), 'amount_rials' => $rev]; $weekPay += $rev; if ($i === 0) { $todayPay = $rev; } } return ['revenue' => $series, 'today_payments_rials' => $todayPay, 'week_payments_rials' => $weekPay]; } /** * سری ۷ روز اخیر تعداد نوبت با شمارنده‌ی دلخواه (doctor/clinic). * @param callable(int $dayStart, int $dayEnd): int $counter * @return array */ private function appointmentsDaily(callable $counter): array { $fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM'); $series = []; for ($i = 6; $i >= 0; $i--) { $ds = strtotime('today midnight') - $i * 86400; $de = $ds + 86399; $series[] = ['label' => $fmt->format($ds), 'count' => $counter($ds, $de)]; } return $series; } // ── Secretary Dashboard ────────────────────────────────────────────────── #[Route('/api/v1/dashboard/secretary', methods: ['GET'])] #[IsGranted('ROLE_SECRETARY')] public function secretary(#[CurrentUser] User $user): JsonResponse { $activeCtx = $this->contextRepo->findByUser($user); $dbUuid = $activeCtx?->getDbUuid(); if ($dbUuid === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } // تعیین scope بر اساس db_uuid فعال $clinic = $this->clinicRepo->findByUuid($dbUuid); if ($clinic !== null) { return $this->secretaryClinicDashboard($user, $clinic); } $doctor = $this->doctorRepo->findByUuid($dbUuid); if ($doctor !== null) { $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); if ($rel === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } return $this->secretaryDoctorDashboard($user, $rel); } return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403); } private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse { $doctor = $rel->getDoctor(); $permissions = $rel->getPermissions(); $canView = (bool) ($permissions['resources']['appointments']['view'] ?? false); $todayStart = strtotime('today midnight'); $todayEnd = strtotime('tomorrow midnight') - 1; $tmrStart = strtotime('tomorrow midnight'); $tmrEnd = strtotime('tomorrow midnight') + 86399; $todayCount = (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e ')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd]) ->getSingleScalarResult(); $tmrCount = (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e ')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd]) ->getSingleScalarResult(); $todayAppts = []; if ($canView) { $todayAppts = $this->em->createQuery(' SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, a.slotStart AS slot_start, a.status FROM App\Appointment\Entity\Appointment a JOIN a.user u WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e ORDER BY a.slotStart ASC ')->setMaxResults(10)->setParameters([ 'doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd, ])->getArrayResult(); } return $this->success([ 'scope' => 'doctor', 'doctor' => [ 'uuid' => $doctor->getUuid(), 'name' => $doctor->getName(), 'degree' => $doctor->getDegree(), ], 'permissions' => $permissions, 'stats' => [ 'today_appointments' => $todayCount, 'tomorrow_appointments' => $tmrCount, ], 'today_appointments' => $todayAppts, ]); } private function secretaryClinicDashboard(User $user, \App\Clinic\Entity\Clinic $clinic): JsonResponse { $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); if ($rel === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } $permissions = $rel->getPermissions(); $canView = (bool) ($permissions['resources']['appointments']['view'] ?? false); $todayStart = strtotime('today midnight'); $todayEnd = strtotime('tomorrow midnight') - 1; $tmrStart = strtotime('tomorrow midnight'); $tmrEnd = strtotime('tomorrow midnight') + 86399; $doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic); $todayCount = 0; $tmrCount = 0; $todayAppts = []; if (!empty($doctors)) { $todayCount = (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e ')->setParameters(['doctors' => $doctors, 's' => $todayStart, 'e' => $todayEnd]) ->getSingleScalarResult(); $tmrCount = (int) $this->em->createQuery(' SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e ')->setParameters(['doctors' => $doctors, 's' => $tmrStart, 'e' => $tmrEnd]) ->getSingleScalarResult(); if ($canView) { $todayAppts = $this->em->createQuery(' SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, a.slotStart AS slot_start, a.status, d.name AS doctor_name FROM App\Appointment\Entity\Appointment a JOIN a.user u JOIN a.doctor d WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e ORDER BY a.slotStart ASC ')->setMaxResults(20)->setParameters([ 'doctors' => $doctors, 's' => $todayStart, 'e' => $todayEnd, ])->getArrayResult(); } } return $this->success([ 'scope' => 'clinic', 'clinic' => [ 'uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), ], 'permissions' => $permissions, 'stats' => [ 'today_appointments' => $todayCount, 'tomorrow_appointments' => $tmrCount, ], 'today_appointments' => $todayAppts, ]); } }