feat(doctors): search every specialty a doctor has, and expose the tree

`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.

`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".

Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.

toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.

Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.

Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 16:44:32 +03:30
co-authored by Claude Opus 5
parent 08a344c99d
commit 2da5b5188c
9 changed files with 808 additions and 29 deletions
+48 -4
View File
@@ -7,6 +7,8 @@ use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Tools\Pagination\Paginator;
@@ -14,11 +16,30 @@ use Doctrine\Persistence\ManagerRegistry;
class DoctorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
public function __construct(
ManagerRegistry $registry,
private readonly SpecialtyRepository $specialtyRepo,
) {
parent::__construct($registry, Doctor::class);
}
/**
* شرطِ «این پزشک دست‌کم یکی از این تخصص‌ها را دارد» به‌شکل زیرکوئری مستقل.
*
* هر فیلترِ مربوط به تخصص alias خودش را می‌گیرد. اگر همه روی یک alias بنشینند،
* DQL مجبور می‌شود یک ردیفِ join همهٔ شرط‌ها را با هم ارضا کند و ترکیبِ دو فیلتر
* بی‌صدا نتیجه را تنگ می‌کند.
*/
private function hasAnySpecialty(string $alias, string $param): string
{
return sprintf(
'EXISTS(SELECT %1$s.id FROM %2$s %1$s WHERE %1$s MEMBER OF d.specialties AND %1$s.id IN (:%3$s))',
$alias,
Specialty::class,
$param,
);
}
public function findByUuid(string $uuid): ?Doctor
{
return $this->findOneBy(['uuid' => $uuid]);
@@ -73,8 +94,20 @@ class DoctorRepository extends ServiceEntityRepository
}
$qb->andWhere($orX)->setParameter('city', $cityId);
}
// «این تخصص» یعنی خودش و همهٔ زیرشاخه‌هایش. تا امروز این فقط به‌خاطر عارضهٔ
// جانبیِ expandWithAncestors هنگام ذخیره کار می‌کرد؛ پزشکی که از مسیر دیگری
// (مثلاً import دسته‌ای) وارد شود آن والدِ denormalize‌شده را ندارد.
//
// زیرکوئری جداست و از alias مشترک `s` استفاده نمی‌کند: آن alias فیلتر نام
// تخصص را هم حمل می‌کند، و دو شرط روی یک alias یعنی یک ردیفِ join باید هر دو
// را با هم ارضا کند — پزشکی که با تخصص A فیلتر را پاس می‌کند و نام تخصص B را
// دارد بی‌صدا حذف می‌شد.
if (!empty($filters['specialty_id'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
$qb->andWhere($this->hasAnySpecialty('sf', 'specialtyIds'))
->setParameter(
'specialtyIds',
$this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']])
);
}
// Scope دامنه‌ی نماینده‌ی سراسری (تزریق‌شده توسط DomainContextResolver در کنترلر).
if (!empty($filters['representation_id'])) {
@@ -86,8 +119,19 @@ class DoctorRepository extends ServiceEntityRepository
if (!empty($filters['degree'])) {
$qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']);
}
// کادر جستجوی سایت عمومی یک فیلد بیشتر ندارد و کاربر در آن هم نام پزشک تایپ
// می‌کند و هم نام تخصص. alias جداگانه می‌گیرد تا با فیلتر specialty_id روی یک
// ردیفِ join گره نخورد؛ وگرنه ترکیبِ دو فیلتر بی‌صدا نتیجه را تنگ می‌کرد.
if (!empty($filters['name'])) {
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
$qb->andWhere(
$qb->expr()->orX(
'd.name LIKE :name',
sprintf(
'EXISTS(SELECT sn.id FROM %s sn WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name)',
Specialty::class,
),
)
)->setParameter('name', '%' . $filters['name'] . '%');
}
// "دارای نوبت" = appointment flag on AND a weekly schedule exists with
// online booking not disabled AND at least one active session — same