`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>
308 lines
14 KiB
Markdown
308 lines
14 KiB
Markdown
# جستجوی پزشک روی همهٔ تخصصها و برگرداندن ساختار والد/فرزند
|
||
|
||
## پروژه
|
||
|
||
`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()` یکتا بساز تا اجرای دوم هم سبز بماند.
|