findOneBy(['uuid' => $uuid]); } public function findByUser(User $user): ?Doctor { return $this->findOneBy(['user' => $user]); } public function findOneByMobile(string $mobile): ?Doctor { return $this->createQueryBuilder('d') ->join('d.user', 'u') ->where('u.mobileNumber = :mobile') ->setParameter('mobile', $mobile) ->setMaxResults(1) ->getQuery() ->getOneOrNullResult(); } public function findWithFilters(array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1)); $limit = min(50, max(1, (int) ($filters['limit'] ?? 10))); $sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; // Location comes from the doctor's own address (doctor_addresses), plus a // fallback to the clinic address for doctors listed under a clinic. $qb = $this->createQueryBuilder('d') ->leftJoin('d.specialties', 's') ->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d') ->distinct(); if (!empty($filters['state_id'])) { $stateId = (int) $filters['state_id']; $clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId); $orX = $qb->expr()->orX('IDENTITY(da.province) = :state'); if ($clinicIds) { $orX->add('d.id IN (:stateClinicDoctorIds)'); $qb->setParameter('stateClinicDoctorIds', $clinicIds); } $qb->andWhere($orX)->setParameter('state', $stateId); } if (!empty($filters['city_id'])) { $cityId = (int) $filters['city_id']; $clinicIds = $this->doctorIdsViaClinicLocation('city', $cityId); $orX = $qb->expr()->orX('IDENTITY(da.city) = :city'); if ($clinicIds) { $orX->add('d.id IN (:cityClinicDoctorIds)'); $qb->setParameter('cityClinicDoctorIds', $clinicIds); } $qb->andWhere($orX)->setParameter('city', $cityId); } if (!empty($filters['specialty_id'])) { $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']); } // Scope دامنه‌ی نماینده‌ی سراسری (تزریق‌شده توسط DomainContextResolver در کنترلر). if (!empty($filters['representation_id'])) { $qb->andWhere('d.representationId = :repId')->setParameter('repId', (int) $filters['representation_id']); } if (!empty($filters['gender'])) { $qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']); } if (!empty($filters['degree'])) { $qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']); } if (!empty($filters['name'])) { $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%'); } // Public list shows only doctors with appointment enabled unless the // caller explicitly asks otherwise (e.g. admin passing active=0). $active = isset($filters['active']) ? (bool) $filters['active'] : true; $qb->andWhere('d.activeDoctorAppointment = :active') ->setParameter('active', $active); $qb->orderBy('d.doctorRate', $sort); $total = (new Paginator($qb))->count(); $results = $qb->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit) ->getQuery() ->getResult(); return [ 'items' => $results, 'total' => $total, 'page' => $page, 'limit' => $limit, 'totalPages' => (int) ceil($total / $limit), ]; } /** * IDs of doctors who belong to a clinic whose own address (DoctorAddress with * doctor IS NULL) is in the given city/province. Used so doctors without a * direct address still surface under their clinic's location, without a * per-row correlated subquery on the main search. * * @return int[] */ private function doctorIdsViaClinicLocation(string $field, int $locationId): array { $column = $field === 'city' ? 'ca.city' : 'ca.province'; $rows = $this->getEntityManager()->createQueryBuilder() ->select('cd.id AS doctorId') ->from(Clinic::class, 'cl') ->join('cl.doctors', 'cd') ->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') ->where(sprintf('IDENTITY(%s) = :loc', $column)) ->setParameter('loc', $locationId) ->getQuery() ->getScalarResult(); return array_values(array_unique(array_map('intval', array_column($rows, 'doctorId')))); } public function findByClinicWithFilters(int $clinicId, array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1)); $limit = min(50, max(1, (int) ($filters['limit'] ?? 10))); $sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; // Doctor has no inverse 'clinics' relation; the ManyToMany is owned by // Clinic.doctors. Join Clinic and match its doctors collection to d. $qb = $this->createQueryBuilder('d') ->innerJoin(Clinic::class, 'c', Join::WITH, 'd MEMBER OF c.doctors') ->leftJoin('d.specialties', 's') ->where('c.id = :clinicId') ->setParameter('clinicId', $clinicId) ->distinct(); if (!empty($filters['specialty'])) { $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']); } if (!empty($filters['gender'])) { $qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']); } if (!empty($filters['degree'])) { $qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']); } if (!empty($filters['name'])) { $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%'); } if (isset($filters['active'])) { $qb->andWhere('d.activeDoctorAppointment = :active') ->setParameter('active', (bool) $filters['active']); } // Hydrate specialties in the same query so toListArray() doesn't lazy-load // them per doctor (N+1). fetchJoinCollection keeps LIMIT paginating by // doctor, not by joined rows. $qb->addSelect('s') ->orderBy('d.doctorRate', $sort) ->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit); $paginator = new Paginator($qb, fetchJoinCollection: true); $total = count($paginator); $results = iterator_to_array($paginator); return [ 'items' => $results, 'total' => $total, 'page' => $page, 'limit' => $limit, 'totalPages' => (int) ceil($total / $limit), ]; } public function save(Doctor $doctor, bool $flush = true): void { $this->getEntityManager()->persist($doctor); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(Doctor $doctor, bool $flush = true): void { $this->getEntityManager()->remove($doctor); if ($flush) { $this->getEntityManager()->flush(); } } }