# جستجوی پزشک روی همهٔ تخصص‌ها و برگرداندن ساختار والد/فرزند ## پروژه `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 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 $ids * @return list */ 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=` باید او را برگرداند. همچنین `?specialty_id=` نباید پزشکِ یک زیرتخصصِ خواهر را برگرداند. ### ۳. `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=&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()` یکتا بساز تا اجرای دوم هم سبز بماند.