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:
@@ -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()` یکتا بساز تا اجرای دوم هم سبز بماند.
|
||||
Reference in New Issue
Block a user