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
@@ -0,0 +1,307 @@
# جستجوی پزشک روی همهٔ تخصص‌ها و برگرداندن ساختار والد/فرزند
## پروژه
`clinicpro` — بک‌اند Symfony.
**cross-repo.** قرارداد `GET /api/v1/doctors` را سایت عمومی مصرف می‌کند.
پرامپت همتا: `nobat724_front/.claude/prompt/doctor-multi-specialty-ui.md`.
آن را **بعد از** این یکی اجرا کن — کارت پزشک به `parent_id` در پاسخ همین اندپوینت نیاز دارد.
## زمینه
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب،
شناسهٔ ۱۴۹۹۲:
```
13 جراحی عمومی (والد)
14 جراحی پلاستیک و زیبایی
167 جراحی لاپاراسکوپی
168 جراح تیروئید
169 جراح گوارش
170 جراحی سرطانها
```
هنگام ذخیره، `expandWithAncestors` والدها را خودکار اضافه می‌کند، پس والد معمولاً روی پزشک نشسته است.
## مشکل / هدف
سه شکاف در بک‌اند:
۱. **جستجوی متنی تخصص را نمی‌بیند.** تایپ «جراح گوارش» در کادر جستجو هیچ پزشکی برنمی‌گرداند،
چون فقط `d.name` گشته می‌شود.
۲. **فیلتر `specialty_id` به فرزندان گسترش نمی‌یابد.** امروز فقط به‌خاطر عارضهٔ جانبیِ
`expandWithAncestors` در زمان ذخیره کار می‌کند. هر پزشکی که از مسیر دیگری وارد شود
(مثلاً import دسته‌ای) این تضمین را ندارد. تکیهٔ یک قابلیت جستجو بر یک side effect ذخیره، شکننده است.
۳. **پاسخ لیست `parent_id` ندارد.** کارت پزشک در سایت عمومی نمی‌تواند تشخیص دهد کدام تخصص
والد است، پس نمی‌تواند «تخصص اصلی + N» بسازد.
## معیار پذیرش
- ✅ موفق: `GET /api/v1/doctors?specialty_id=13` پزشکی را که **فقط** `169` (جراح گوارش) دارد و
والد روی او ثبت نشده هم برمی‌گرداند.
- ✅ موفق: `GET /api/v1/doctors?name=جراح گوارش` دکتر جهانتاب را برمی‌گرداند.
- ✅ موفق: `GET /api/v1/doctors?name=جهانتاب` همچنان همان پزشک را برمی‌گرداند — تطابق نام نشکسته.
- ✅ موفق: هر آیتم پاسخ، در `specialties[]` کلید `parent_id` دارد؛ برای ریشه `null`.
- ❌ خطا: `specialty_id` با شناسهٔ ناموجود → `200` و آرایهٔ خالی، نه `500`.
- ❌ خطا: `name` با عبارتی که به هیچ پزشک و هیچ تخصصی نمی‌خورد → `200` و `totalRecords: 0`.
- ⚠️ مرزی: **`specialty_id` و `name` با هم**. `?specialty_id=13&name=جهانتاب` باید همان پزشک را
بدهد. این حالت با پیاده‌سازی ساده می‌شکند — پایین توضیح داده شده.
- ⚠️ مرزی: تخصص برگ (بدون فرزند) — `?specialty_id=169` فقط پزشکان همان تخصص، نه کل گروه.
- ⚠️ مرزی: `totalRecords` باید با تعداد ردیف‌های واقعی بخواند. join چندبه‌چند بدون `DISTINCT`
در شمارش، پزشک چندتخصصی را چند بار می‌شمارد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Specialty/Repository/SpecialtyRepository.php` | `parentMap()` و `expandWithAncestors()` موجود |
| `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — همهٔ فیلترهای لیست عمومی |
| `src/Doctor/Entity/Doctor.php` | `toListArray()` — شکل آیتم لیست |
| `docs/api/doctor.md` | سند اندپوینت |
| `docs/api/specialty.md` | سند تخصص |
## وضعیت فعلی
### فیلترها — تک‌شناسه و فقط نام پزشک
```php
// src/Doctor/Repository/DoctorRepository.php:51
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
->distinct();
```
```php
// همان فایل:76
if (!empty($filters['specialty_id'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
}
```
```php
// همان فایل:89
if (!empty($filters['name'])) {
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
}
```
### گسترش به بالا وجود دارد، به پایین نه
```php
// src/Specialty/Repository/SpecialtyRepository.php:75
public function expandWithAncestors(array $ids): array
{
$map = $this->parentMap();
$out = [];
foreach ($ids as $id) {
$cur = (int) $id;
$seen = [];
while (array_key_exists($cur, $map) && !isset($seen[$cur])) {
$seen[$cur] = true;
$out[$cur] = true;
$cur = $map[$cur] ?? 0;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
/** @return array<int,?int> id => parentId for every specialty */
private function parentMap(): array
{
if ($this->parentMap === null) {
$rows = $this->createQueryBuilder('s')
->select('s.id AS id', 'IDENTITY(s.parent) AS parent')
->getQuery()
->getArrayResult();
$this->parentMap = [];
foreach ($rows as $row) {
$this->parentMap[(int) $row['id']] = $row['parent'] !== null ? (int) $row['parent'] : null;
}
}
return $this->parentMap;
}
```
### پاسخ لیست — بدون parent_id
```php
// src/Doctor/Entity/Doctor.php:578
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
], $this->specialties->toArray()),
```
## وظایف
### ۱. `expandWithDescendants` در `SpecialtyRepository`
قرینهٔ `expandWithAncestors`. از همان `parentMap()` کش‌شده استفاده می‌کند، پس کوئری اضافه ندارد.
درخت امروز دقیقاً دو سطح است — شمارش نوه‌ها صفر است — ولی مثل قرینه‌اش **generic** بنویس،
نه یک‌سطحی. عمقِ فرضی، بدهیِ فردا است.
```php
/**
* شناسه‌های داده‌شده به‌علاوهٔ همهٔ نوادگانشان.
*
* قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای
* «این گروه یعنی همهٔ زیرشاخه‌هایش هم».
*
* @param list<int|string> $ids
* @return list<int>
*/
public function expandWithDescendants(array $ids): array
{
$map = $this->parentMap();
$children = [];
foreach ($map 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;
}
$out[$cur] = true;
foreach ($children[$cur] ?? [] as $child) {
$queue[] = $child;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
```
`isset($out[$cur])` هم dedupe است و هم محافظ حلقه، اگر داده‌ای چرخه بسازد.
**نحوه تست:** unit test در `tests/Specialty/SpecialtyDescendantsTest.php`
`expandWithDescendants([13])` باید `13` و همهٔ فرزندانش را بدهد؛
`expandWithDescendants([169])` فقط `[169]`؛ آرایهٔ خالی → آرایهٔ خالی؛
شناسهٔ ناموجود → همان شناسه بدون خطا.
### ۲. فیلتر `specialty_id` به نوادگان گسترش یابد
**دقت کن — اینجا یک تلهٔ واقعی هست.** alias فعلی `s` هم برای فیلتر تخصص استفاده می‌شود و هم
(در وظیفهٔ ۳) برای جستجوی نام تخصص. اگر هر دو روی همان alias بنشینند، DQL مجبور می‌شود
**یک ردیفِ join** هر دو شرط را با هم ارضا کند. پزشکی که با تخصص A فیلتر را پاس می‌کند و
نامِ تخصص B را دارد، حذف می‌شود.
پس هر دو فیلتر را با زیرکوئری `EXISTS` بنویس و join اصلی `s` را دست نزن — آن فقط برای
hydration است.
```php
if (!empty($filters['specialty_id'])) {
$ids = $this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']]);
$qb->andWhere(
$qb->expr()->exists(
'SELECT sf.id FROM ' . Specialty::class . ' sf'
. ' WHERE sf MEMBER OF d.specialties AND sf.id IN (:specialtyIds)'
)
)->setParameter('specialtyIds', $ids);
}
```
`SpecialtyRepository` را با constructor injection بگیر، `new` نکن.
**نحوه تست:** تست فانکشنال در `tests/Doctor/DoctorSpecialtySearchTest.php`.
پزشکی بساز که **فقط** یک زیرتخصص دارد و والد روی او ثبت **نیست** (مستقیم با
`$doctor->getSpecialties()->add($child)` و flush، نه از راه اندپوینت که والد را اضافه می‌کند).
سپس `GET /api/v1/doctors?specialty_id=<parentId>` باید او را برگرداند.
همچنین `?specialty_id=<leafId>` نباید پزشکِ یک زیرتخصصِ خواهر را برگرداند.
### ۳. `name` نام تخصص را هم بگردد
```php
if (!empty($filters['name'])) {
$qb->andWhere(
$qb->expr()->orX(
'd.name LIKE :name',
$qb->expr()->exists(
'SELECT sn.id FROM ' . Specialty::class . ' sn'
. ' WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name'
)
)
)->setParameter('name', '%' . $filters['name'] . '%');
}
```
ترتیب نتایج را عوض نکن. مرتب‌سازی فعلی سر جایش می‌ماند؛ اگر بعداً «نام پزشک اول» خواسته شد،
تسک جداست.
**نحوه تست:** در همان تست فانکشنال —
`?name=<نام تخصص>` پزشک را می‌دهد؛ `?name=<بخشی از نام پزشک>` همچنان می‌دهد؛
`?specialty_id=<parentId>&name=<نام پزشک>` هم می‌دهد (همان حالت مرزی که با alias مشترک می‌شکست)؛
عبارت بی‌ربط → `totalRecords: 0`.
### ۴. `parent_id` در پاسخ لیست
```php
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
// کلاینت بدون این نمی‌تواند «تخصص اصلی» را از زیرتخصص تشخیص دهد.
'parent_id' => $s->getParent()?->getId(),
], $this->specialties->toArray()),
```
افزودنی است و هیچ کلید موجودی را عوض نمی‌کند.
**مراقب N+1 باش.** `getParent()` روی proxy یعنی یک کوئری به‌ازای هر تخصص به‌ازای هر پزشک.
در `findWithFilters` روی همان join موجود `addSelect('s')` بگذار و والد را هم join و select کن.
با تست شمارش کوئری تثبیتش کن — `ApiTestCase::countQueries()` برای همین هست.
**نحوه تست:** `GET /api/v1/doctors?limit=10` و بررسی اینکه هر آیتم `parent_id` دارد و برای
ریشه `null` است. سپس `countQueries()` دور همان فراخوانی: تعداد کوئری با ۱۰ پزشک نباید
به‌طور معنادار از حالت ۱ پزشک بیشتر باشد.
### ۵. مستندات
`docs/api/doctor.md` — بخش `GET /api/v1/doctors`:
- `specialty_id` حالا «این تخصص و همهٔ زیرشاخه‌هایش» است. صریح بنویس، چون معنایش عوض شده.
- `name` حالا نام پزشک **یا** نام هر یک از تخصص‌هایش را می‌گردد.
- شکل `specialties[]` با `parent_id` تازه، و **JSON واقعی از اجرای واقعی** نه دست‌ساز.
`docs/api/specialty.md` — یادآوری کن که `GET /api/v1/specialties` بدون `parent_id` همهٔ
تخصص‌های فعال را با `parent_id` و `slug` می‌دهد و **سایت عمومی از همین برای ساخت
`data/specialties.json` در زمان build استفاده می‌کند**. اگر شکل این پاسخ عوض شود، آن اسکریپت می‌شکند.
## نکات مهم
- **تلهٔ alias مشترک، مهم‌ترین نکتهٔ این تسک است.** اگر وظیفهٔ ۲ و ۳ هر دو روی `s` بنویسند،
تست‌های تکی سبز می‌شوند و فقط حالت ترکیبیِ `specialty_id` و `name` می‌شکند — یعنی همان چیزی
که کاربر در فیلترها با هم می‌زند.
- **`DISTINCT` و شمارش.** `findWithFilters` الان `->distinct()` دارد. مطمئن شو کوئری شمارش هم
همان تمایز را دارد، وگرنه `totalRecords` برای پزشک چندتخصصی باد می‌کند و صفحه‌بندی می‌شکند.
- **این تغییر هیچ فیلتری را تنگ نمی‌کند، فقط باز می‌کند.** اگر تستی از رفتار فعلی لیست عمومی
شکست، یعنی زیرکوئری اشتباه بسته شده.
- **`expandWithAncestors` را دست نزن.** آن در `hydrateDoctor` و `RepresentationActionController`
استفاده می‌شود و کارِ دیگری می‌کند. دو تابع، دو جهت.
- **Repository نازک، Controller نازک‌تر.** منطق گسترش در `SpecialtyRepository` می‌ماند، نه در
`DoctorRepository` و نه در کنترلر.
- **cross-repo:** بعد از این تغییر، `nobat724_front` باید دستی بررسی شود. تغییر شکل پاسخ در
build آن خطا نمی‌دهد و فقط در رانتایم دیده می‌شود. صفحهٔ `/doctors` و `/specialties/[slug]`
را باز کن و مطمئن شو چیزی نشکسته.
- دیتابیس تست هرگز reset نمی‌شود؛ دادهٔ هر تست را با `uniqid()` یکتا بساز تا اجرای دوم هم سبز بماند.