Files
clinicpro/.claude/prompt/doctor-multi-specialty-search.md
hamedandClaude Opus 5 2da5b5188c 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>
2026-08-08 16:44:32 +03:30

14 KiB
Raw Permalink Blame History

جستجوی پزشک روی همهٔ تخصص‌ها و برگرداندن ساختار والد/فرزند

پروژه

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 سند تخصص

وضعیت فعلی

فیلترها — تک‌شناسه و فقط نام پزشک

// src/Doctor/Repository/DoctorRepository.php:51
$qb = $this->createQueryBuilder('d')
    ->leftJoin('d.specialties', 's')
    ->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
    ->distinct();
// همان فایل:76
if (!empty($filters['specialty_id'])) {
    $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
}
// همان فایل:89
if (!empty($filters['name'])) {
    $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
}

گسترش به بالا وجود دارد، به پایین نه

// 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

// 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 بنویس، نه یک‌سطحی. عمقِ فرضی، بدهیِ فردا است.

/**
 * شناسه‌های داده‌شده به‌علاوهٔ همهٔ نوادگانشان.
 *
 * قرینهٔ 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.phpexpandWithDescendants([13]) باید 13 و همهٔ فرزندانش را بدهد؛ expandWithDescendants([169]) فقط [169]؛ آرایهٔ خالی → آرایهٔ خالی؛ شناسهٔ ناموجود → همان شناسه بدون خطا.

۲. فیلتر specialty_id به نوادگان گسترش یابد

دقت کن — اینجا یک تلهٔ واقعی هست. alias فعلی s هم برای فیلتر تخصص استفاده می‌شود و هم (در وظیفهٔ ۳) برای جستجوی نام تخصص. اگر هر دو روی همان alias بنشینند، DQL مجبور می‌شود یک ردیفِ join هر دو شرط را با هم ارضا کند. پزشکی که با تخصص A فیلتر را پاس می‌کند و نامِ تخصص B را دارد، حذف می‌شود.

پس هر دو فیلتر را با زیرکوئری EXISTS بنویس و join اصلی s را دست نزن — آن فقط برای hydration است.

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 نام تخصص را هم بگردد

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 در پاسخ لیست

'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() یکتا بساز تا اجرای دوم هم سبز بماند.