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
@@ -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
{