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:
@@ -235,10 +235,18 @@ class DoctorController extends BaseController
|
||||
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: 20)),
|
||||
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID', schema: new OA\Schema(type: 'integer')),
|
||||
// `search` اینجا تبلیغ میشد اما findWithFilters هرگز آن را نمیخواند —
|
||||
// کلاینتی که میفرستادش بیصدا لیستِ فیلترنشده میگرفت. جایش `name` است
|
||||
// که واقعاً کار میکند. بقیه هم پیاده بودند و مستند نبودند.
|
||||
new OA\Parameter(name: 'name', in: 'query', required: false, description: 'LIKE on the doctor name or any of their specialty names', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID — this specialty and every descendant below it', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'city_id', in: 'query', required: false, description: 'City ID — matches the doctor address city or the clinic address city', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'state_id', in: 'query', required: false, description: 'Province ID — matches the doctor address province or the clinic address province', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'gender', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['man', 'woman'])),
|
||||
new OA\Parameter(name: 'degree', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['expert', 'general', 'specialist', 'subspecialistplus'])),
|
||||
new OA\Parameter(name: 'sort', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['ASC', 'DESC'], default: 'DESC')),
|
||||
new OA\Parameter(name: 'active', in: 'query', required: false, description: '1 → bookable doctors only; 0 → the appointment flag explicitly off', schema: new OA\Schema(type: 'integer', enum: [0, 1])),
|
||||
new OA\Parameter(name: 'domain', in: 'query', required: false, description: 'Requesting site domain; a global-representative domain scopes the list to that representative', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
|
||||
@@ -575,10 +575,17 @@ class Doctor
|
||||
'gender' => $this->gender,
|
||||
'degree' => $this->degree,
|
||||
'img' => $this->images ?? [],
|
||||
// parent_id لازم است تا کلاینت «تخصص اصلی» را از زیرتخصص تشخیص دهد؛
|
||||
// بدون آن کارت پزشک ناچار است همهٔ نامها را پشتسرهم چاپ کند.
|
||||
//
|
||||
// رشته است نه عدد، تا با toDetailArray همشکل بماند — کلاینت نباید برای
|
||||
// یک مفهوم دو قاعدهٔ نوع بنویسد. خواندنِ شناسه از proxy والد کوئری اضافه
|
||||
// نمیزند؛ شناسه از قبل معلوم است.
|
||||
'specialties' => array_map(fn(Specialty $s) => [
|
||||
'uuid' => $s->getUuid(),
|
||||
'id' => (string) $s->getId(),
|
||||
'name' => $s->getName(),
|
||||
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
|
||||
], $this->specialties->toArray()),
|
||||
'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null,
|
||||
'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,47 @@ class SpecialtyRepository extends ServiceEntityRepository
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialty ids plus every descendant below them, unique and sorted.
|
||||
*
|
||||
* قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای
|
||||
* «این گروه یعنی همهٔ زیرشاخههایش هم».
|
||||
*
|
||||
* برخلاف قرینهاش، شناسهٔ ناشناس **حذف نمیشود**: خروجی این متد مستقیم در یک
|
||||
* `IN (...)` مینشیند، و آرایهٔ خالی یعنی یا خطای SQL یا فیلترِ خنثی که همه را
|
||||
* برمیگرداند. شناسهٔ ناموجود باید به «هیچ نتیجهای» ترجمه شود، نه «همه».
|
||||
*
|
||||
* @param list<int|string> $ids
|
||||
* @return list<int>
|
||||
*/
|
||||
public function expandWithDescendants(array $ids): array
|
||||
{
|
||||
$children = [];
|
||||
foreach ($this->parentMap() as $id => $parent) {
|
||||
if ($parent !== null) {
|
||||
$children[$parent][] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$queue = array_map('intval', $ids);
|
||||
while ($queue) {
|
||||
$cur = array_pop($queue);
|
||||
if (isset($out[$cur])) {
|
||||
continue; // هم dedupe، هم محافظِ حلقه اگر داده چرخه بسازد
|
||||
}
|
||||
$out[$cur] = true;
|
||||
foreach ($children[$cur] ?? [] as $child) {
|
||||
$queue[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($out);
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int,?int> id => parentId for every specialty */
|
||||
private function parentMap(): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user