From 2da5b5188cc6aabf8cae6f60c99796eb4aa5eeb4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 16:44:32 +0330 Subject: [PATCH] feat(doctors): search every specialty a doctor has, and expose the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../prompt/doctor-multi-specialty-search.md | 307 ++++++++++++++++++ docs/api/doctor.md | 12 +- docs/api/specialty.md | 52 +-- src/Doctor/Controller/DoctorController.php | 12 +- src/Doctor/Entity/Doctor.php | 7 + src/Doctor/Repository/DoctorRepository.php | 52 ++- .../Repository/SpecialtyRepository.php | 41 +++ tests/Doctor/DoctorSpecialtySearchTest.php | 250 ++++++++++++++ tests/Specialty/SpecialtyDescendantsTest.php | 104 ++++++ 9 files changed, 808 insertions(+), 29 deletions(-) create mode 100644 .claude/prompt/doctor-multi-specialty-search.md create mode 100644 tests/Doctor/DoctorSpecialtySearchTest.php create mode 100644 tests/Specialty/SpecialtyDescendantsTest.php diff --git a/.claude/prompt/doctor-multi-specialty-search.md b/.claude/prompt/doctor-multi-specialty-search.md new file mode 100644 index 00000000..dc729143 --- /dev/null +++ b/.claude/prompt/doctor-multi-specialty-search.md @@ -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 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()` یکتا بساز تا اجرای دوم هم سبز بماند. diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 0a38c161..6e62a816 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -233,14 +233,13 @@ List doctors with pagination and filters. |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 10. **حداکثر ۵۰** — مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند. مقدار واقعاً اعمال‌شده در `meta.limit` برمی‌گردد؛ برای پیمایش کامل به `meta.totalPages` تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی» | -| `search` | string | ❌ | Search in title | -| `specialty_id` | integer | ❌ | Filter by specialty ID | +| `specialty_id` | integer | ❌ | Filter by specialty ID — **این تخصص و همهٔ زیرشاخه‌هایش**. `specialty_id` یک تخصص والد یعنی «همهٔ پزشکان آن گروه»، حتی پزشکی که فقط با یک زیرتخصص ثبت شده و والد روی او ننشسته. شناسهٔ ناموجود → لیست خالی، نه لیست کامل | | `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصی‌شان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) | | `state_id` | integer | ❌ | Filter by province ID — بر اساس آدرس شخصی پزشک (`doctor_addresses.province_id`) یا آدرس کلینیک | | `domain` | string | ❌ | دامنه‌ی سایتِ درخواست‌کننده. اگر دامنه‌ی یک **نماینده سراسری** باشد، فقط پزشکانِ همان نماینده برمی‌گردند و `city_id`/`state_id` نادیده گرفته می‌شوند؛ دامنه شهری/ناشناخته اثری ندارد | | `gender` | string | ❌ | `man` یا `woman` | | `degree` | string | ❌ | `expert`, `general`, `specialist`, `subspecialistplus` | -| `name` | string | ❌ | جستجوی `LIKE` روی نام پزشک | +| `name` | string | ❌ | جستجوی `LIKE` روی نام پزشک **یا نام هر یک از تخصص‌های او**. کادر جستجوی سایت عمومی یک فیلد بیشتر ندارد و کاربر در آن هر دو را تایپ می‌کند. با `specialty_id` مستقل ترکیب می‌شود: پزشکی که با یک تخصص فیلتر را پاس می‌کند و نامِ تخصص دیگرش را می‌جویند، حذف نمی‌شود | | `sort` | string | ❌ | `ASC` یا `DESC` (پیش‌فرض `DESC`) — مرتب‌سازی ثانویه بر اساس `doctorRate` | | `active` | `0`/`1` | ❌ | `1` → فقط پزشکان **دارای نوبت** (تعریف پایین). `0` → فقط پزشکانی که فلگ `active_doctor_appointment` آن‌ها خاموش است (کاربرد ادمین). بدون این پارامتر → فقط پزشکان **فعال** (`active_doctor_appointment` روشن)؛ پزشکان غیرفعال هرگز در لیست عمومی نمی‌آیند | @@ -265,7 +264,10 @@ List doctors with pagination and filters. "gender": "man", "degree": "specialist", "img": [], - "specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق" }], + "specialties": [ + { "uuid": "277d0e01-...", "id": "5179", "name": "جراحی عمومی", "parent_id": null }, + { "uuid": "460bd820-...", "id": "5180", "name": "جراح گوارش", "parent_id": "5179" } + ], "satisfaction": "60", "point": "3.5", "free_turn": "دوشنبه 09:00–13:00", @@ -291,6 +293,8 @@ List doctors with pagination and filters. > ℹ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند. +> ℹ️ هر آیتم `specialties[]` کلید `parent_id` دارد — **رشته** یا `null` برای ریشه، هم‌شکل با `id` در همان آرایه و با پاسخ جزئیات پزشک. کلاینت با همین تشخیص می‌دهد کدام تخصص «اصلی» است؛ پزشک چندتخصصی معمولاً هم والد را دارد و هم زیرشاخه‌هایش، چون ذخیرهٔ یک زیرتخصص والدهایش را هم می‌نشاند. + ### اعتبارسنجی نام پزشک `name` نمی‌تواند شماره‌تلفن (`^0?9\d{9}$`) یا مقدار آزمایشی (`test`، `تست`، `-`، `null`) باشد. این مقادیر در **هر** مسیر نوشتن با `422` رد می‌شوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (`App\Shared\Util\DisplayName`). diff --git a/docs/api/specialty.md b/docs/api/specialty.md index 05e45a94..169b63fe 100644 --- a/docs/api/specialty.md +++ b/docs/api/specialty.md @@ -13,33 +13,47 @@ List all medical specialties. ### Query Parameters | Param | Type | Required | Description | |-------|------|----------|-------------| -| `parent_id` | integer | ❌ | Filter to sub-specialties of a parent | +| `parent_id` | integer | ❌ | Filter to sub-specialties of a parent. **بدون این پارامتر، هر تخصص فعال برمی‌گردد — ریشه‌ها و فرزندان با هم.** | ### Response `200` + +پاسخ **دولایه** است: `data.data`. خروجی واقعی از اجرای زنده (۹۹ رکورد): + ```json { "success": true, - "data": [ - { - "id": 1, - "name": "قلب و عروق", - "slug": "ghalb-va-oroug", - "parent_id": null, - "status": "active", - "weight": 10 - }, - { - "id": 5, - "name": "فوق تخصص قلب", - "slug": "fowgh-takhassos-ghalb", - "parent_id": 1, - "status": "active", - "weight": 5 - } - ] + "data": { + "data": [ + { + "id": 1, + "uuid": "c60b2c3c-de3c-4c2b-a4c9-75c3444e69cb", + "name": "پزشک عمومی", + "slug": "general-practitioner", + "status": 1, + "weight": 10, + "parent_id": null + }, + { + "id": 168, + "uuid": "6828d049-f0b3-421e-930a-4b6ac57c75f1", + "name": "جراح تیروئید", + "slug": "جراح-تیروئید", + "status": 1, + "weight": 0, + "parent_id": 13 + } + ] + } } ``` +`status` عدد است (`1` فعال)، نه رشته. `slug` می‌تواند فارسی باشد. + +> **مصرف‌کنندهٔ حساس:** سایت عمومی `nobat724_front` فایل `data/specialties.json` خود را در زمان +> build از همین اندپوینت می‌سازد و آن فایل به `sitemap` و صفحات تخصص خوراک می‌دهد. تغییر شکل این +> پاسخ آن اسکریپت را می‌شکند و در build کلاینت خطا نمی‌دهد — فقط در رانتایم دیده می‌شود. +``` + --- ## GET `/api/v1/specialties/doctor-counts` diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 6c9d17a6..904acf5a 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -235,10 +235,18 @@ class DoctorController extends BaseController parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)), - new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID', schema: new OA\Schema(type: 'integer')), + // `search` اینجا تبلیغ می‌شد اما findWithFilters هرگز آن را نمی‌خواند — + // کلاینتی که می‌فرستادش بی‌صدا لیستِ فیلترنشده می‌گرفت. جایش `name` است + // که واقعاً کار می‌کند. بقیه هم پیاده بودند و مستند نبودند. + new OA\Parameter(name: 'name', in: 'query', required: false, description: 'LIKE on the doctor name or any of their specialty names', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID — this specialty and every descendant below it', schema: new OA\Schema(type: 'integer')), new OA\Parameter(name: 'city_id', in: 'query', required: false, description: 'City ID — matches the doctor address city or the clinic address city', schema: new OA\Schema(type: 'integer')), new OA\Parameter(name: 'state_id', in: 'query', required: false, description: 'Province ID — matches the doctor address province or the clinic address province', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'gender', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['man', 'woman'])), + new OA\Parameter(name: 'degree', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['expert', 'general', 'specialist', 'subspecialistplus'])), + new OA\Parameter(name: 'sort', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['ASC', 'DESC'], default: 'DESC')), + new OA\Parameter(name: 'active', in: 'query', required: false, description: '1 → bookable doctors only; 0 → the appointment flag explicitly off', schema: new OA\Schema(type: 'integer', enum: [0, 1])), + new OA\Parameter(name: 'domain', in: 'query', required: false, description: 'Requesting site domain; a global-representative domain scopes the list to that representative', schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index db7c1ff3..814151b6 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -575,10 +575,17 @@ class Doctor 'gender' => $this->gender, 'degree' => $this->degree, 'img' => $this->images ?? [], + // parent_id لازم است تا کلاینت «تخصص اصلی» را از زیرتخصص تشخیص دهد؛ + // بدون آن کارت پزشک ناچار است همهٔ نام‌ها را پشت‌سرهم چاپ کند. + // + // رشته است نه عدد، تا با toDetailArray هم‌شکل بماند — کلاینت نباید برای + // یک مفهوم دو قاعدهٔ نوع بنویسد. خواندنِ شناسه از proxy والد کوئری اضافه + // نمی‌زند؛ شناسه از قبل معلوم است. 'specialties' => array_map(fn(Specialty $s) => [ 'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(), + 'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null, ], $this->specialties->toArray()), 'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null, 'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null, diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index e631693c..67ee2e96 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -7,6 +7,8 @@ use App\Auth\Entity\User; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use App\Doctor\Entity\DoctorAddress; +use App\Specialty\Entity\Specialty; +use App\Specialty\Repository\SpecialtyRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Tools\Pagination\Paginator; @@ -14,11 +16,30 @@ use Doctrine\Persistence\ManagerRegistry; class DoctorRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + public function __construct( + ManagerRegistry $registry, + private readonly SpecialtyRepository $specialtyRepo, + ) { parent::__construct($registry, Doctor::class); } + /** + * شرطِ «این پزشک دست‌کم یکی از این تخصص‌ها را دارد» به‌شکل زیرکوئری مستقل. + * + * هر فیلترِ مربوط به تخصص alias خودش را می‌گیرد. اگر همه روی یک alias بنشینند، + * DQL مجبور می‌شود یک ردیفِ join همهٔ شرط‌ها را با هم ارضا کند و ترکیبِ دو فیلتر + * بی‌صدا نتیجه را تنگ می‌کند. + */ + private function hasAnySpecialty(string $alias, string $param): string + { + return sprintf( + 'EXISTS(SELECT %1$s.id FROM %2$s %1$s WHERE %1$s MEMBER OF d.specialties AND %1$s.id IN (:%3$s))', + $alias, + Specialty::class, + $param, + ); + } + public function findByUuid(string $uuid): ?Doctor { return $this->findOneBy(['uuid' => $uuid]); @@ -73,8 +94,20 @@ class DoctorRepository extends ServiceEntityRepository } $qb->andWhere($orX)->setParameter('city', $cityId); } + // «این تخصص» یعنی خودش و همهٔ زیرشاخه‌هایش. تا امروز این فقط به‌خاطر عارضهٔ + // جانبیِ expandWithAncestors هنگام ذخیره کار می‌کرد؛ پزشکی که از مسیر دیگری + // (مثلاً import دسته‌ای) وارد شود آن والدِ denormalize‌شده را ندارد. + // + // زیرکوئری جداست و از alias مشترک `s` استفاده نمی‌کند: آن alias فیلتر نام + // تخصص را هم حمل می‌کند، و دو شرط روی یک alias یعنی یک ردیفِ join باید هر دو + // را با هم ارضا کند — پزشکی که با تخصص A فیلتر را پاس می‌کند و نام تخصص B را + // دارد بی‌صدا حذف می‌شد. if (!empty($filters['specialty_id'])) { - $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']); + $qb->andWhere($this->hasAnySpecialty('sf', 'specialtyIds')) + ->setParameter( + 'specialtyIds', + $this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']]) + ); } // Scope دامنه‌ی نماینده‌ی سراسری (تزریق‌شده توسط DomainContextResolver در کنترلر). if (!empty($filters['representation_id'])) { @@ -86,8 +119,19 @@ class DoctorRepository extends ServiceEntityRepository if (!empty($filters['degree'])) { $qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']); } + // کادر جستجوی سایت عمومی یک فیلد بیشتر ندارد و کاربر در آن هم نام پزشک تایپ + // می‌کند و هم نام تخصص. alias جداگانه می‌گیرد تا با فیلتر specialty_id روی یک + // ردیفِ join گره نخورد؛ وگرنه ترکیبِ دو فیلتر بی‌صدا نتیجه را تنگ می‌کرد. if (!empty($filters['name'])) { - $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%'); + $qb->andWhere( + $qb->expr()->orX( + 'd.name LIKE :name', + sprintf( + 'EXISTS(SELECT sn.id FROM %s sn WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name)', + Specialty::class, + ), + ) + )->setParameter('name', '%' . $filters['name'] . '%'); } // "دارای نوبت" = appointment flag on AND a weekly schedule exists with // online booking not disabled AND at least one active session — same diff --git a/src/Specialty/Repository/SpecialtyRepository.php b/src/Specialty/Repository/SpecialtyRepository.php index de38d548..51e3bb48 100644 --- a/src/Specialty/Repository/SpecialtyRepository.php +++ b/src/Specialty/Repository/SpecialtyRepository.php @@ -93,6 +93,47 @@ class SpecialtyRepository extends ServiceEntityRepository return $out; } + /** + * Specialty ids plus every descendant below them, unique and sorted. + * + * قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای + * «این گروه یعنی همهٔ زیرشاخه‌هایش هم». + * + * برخلاف قرینه‌اش، شناسهٔ ناشناس **حذف نمی‌شود**: خروجی این متد مستقیم در یک + * `IN (...)` می‌نشیند، و آرایهٔ خالی یعنی یا خطای SQL یا فیلترِ خنثی که همه را + * برمی‌گرداند. شناسهٔ ناموجود باید به «هیچ نتیجه‌ای» ترجمه شود، نه «همه». + * + * @param list $ids + * @return list + */ + public function expandWithDescendants(array $ids): array + { + $children = []; + foreach ($this->parentMap() 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; // هم dedupe، هم محافظِ حلقه اگر داده چرخه بسازد + } + $out[$cur] = true; + foreach ($children[$cur] ?? [] as $child) { + $queue[] = $child; + } + } + + $out = array_keys($out); + sort($out); + + return $out; + } + /** @return array id => parentId for every specialty */ private function parentMap(): array { diff --git a/tests/Doctor/DoctorSpecialtySearchTest.php b/tests/Doctor/DoctorSpecialtySearchTest.php new file mode 100644 index 00000000..8ec1791b --- /dev/null +++ b/tests/Doctor/DoctorSpecialtySearchTest.php @@ -0,0 +1,250 @@ +em->persist($root); + $this->em->flush(); + + $childA = new Specialty('زیرشاخهٔ الف ' . $suffix, 'schild-a-' . $suffix, $root); + $childB = new Specialty('زیرشاخهٔ ب ' . $suffix, 'schild-b-' . $suffix, $root); + $this->em->persist($childA); + $this->em->persist($childB); + $this->em->flush(); + + return ['root' => $root, 'childA' => $childA, 'childB' => $childB]; + } + + /** پزشکِ فعال و قابل‌نمایش در لیست عمومی، با دقیقاً همین تخصص‌ها. */ + private function newDoctor(string $name, array $specialties): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), $name); + $doctor->setActiveDoctorAppointment(true); + foreach ($specialties as $s) { + $doctor->getSpecialties()->add($s); + } + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + /** @return list uuidهای پاسخ لیست */ + private function uuidsOf(array $body): array + { + return array_map(static fn(array $d) => $d['uuid'], $body['data']); + } + + private function get(string $query): array + { + $this->client->request('GET', '/api/v1/doctors?limit=50&' . $query); + + return json_decode($this->client->getResponse()->getContent(), true) ?? []; + } + + // ── گسترش به نوادگان ────────────────────────────────────────────────────── + + public function testParentIdFindsADoctorTaggedOnlyWithAChild(): void + { + $t = $this->newTree(); + // هیچ والدی روی پزشک ثبت نشده — همان حالتی که import دسته‌ای می‌سازد. + $doctor = $this->newDoctor('دکتر زیرشاخه ' . uniqid(), [$t['childA']]); + + $body = $this->get('specialty_id=' . $t['root']->getId()); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertContains($doctor->getUuid(), $this->uuidsOf($body)); + } + + public function testParentIdAlsoFindsADoctorTaggedWithTheParentItself(): void + { + $t = $this->newTree(); + $doctor = $this->newDoctor('دکتر ریشه ' . uniqid(), [$t['root']]); + + $body = $this->get('specialty_id=' . $t['root']->getId()); + + self::assertContains($doctor->getUuid(), $this->uuidsOf($body)); + } + + public function testLeafIdDoesNotLeakSiblingDoctors(): void + { + $t = $this->newTree(); + $a = $this->newDoctor('دکتر الف ' . uniqid(), [$t['childA']]); + $b = $this->newDoctor('دکتر ب ' . uniqid(), [$t['childB']]); + + $uuids = $this->uuidsOf($this->get('specialty_id=' . $t['childA']->getId())); + + self::assertContains($a->getUuid(), $uuids); + self::assertNotContains($b->getUuid(), $uuids); + } + + public function testLeafIdDoesNotLeakTheParentsOwnDoctors(): void + { + $t = $this->newTree(); + $onRoot = $this->newDoctor('دکتر فقط ریشه ' . uniqid(), [$t['root']]); + + $uuids = $this->uuidsOf($this->get('specialty_id=' . $t['childA']->getId())); + + self::assertNotContains($onRoot->getUuid(), $uuids); + } + + // ── جستجوی متنی ─────────────────────────────────────────────────────────── + + public function testNameMatchesASpecialtyName(): void + { + $t = $this->newTree(); + $doctor = $this->newDoctor('دکتر بی‌نام‌ونشان ' . uniqid(), [$t['childA']]); + + $uuids = $this->uuidsOf($this->get('name=' . urlencode($t['childA']->getName()))); + + self::assertContains($doctor->getUuid(), $uuids); + } + + public function testNameStillMatchesTheDoctorName(): void + { + $t = $this->newTree(); + $marker = 'یکتانام' . uniqid(); + $doctor = $this->newDoctor($marker, [$t['childA']]); + + $uuids = $this->uuidsOf($this->get('name=' . urlencode($marker))); + + self::assertContains($doctor->getUuid(), $uuids); + } + + public function testSpecialtyFilterAndNameSearchCombineWithoutNarrowingEachOther(): void + { + // حالتِ مرزیِ اصلی: پزشک با تخصص A فیلتر را پاس می‌کند و با نامِ خودش + // جستجو را. اگر هر دو شرط روی یک alias بنشینند، یک ردیفِ join باید هر دو را + // با هم ارضا کند و پزشک بی‌صدا حذف می‌شود. + $t = $this->newTree(); + $marker = 'ترکیبی' . uniqid(); + $doctor = $this->newDoctor($marker, [$t['childA'], $t['childB']]); + + $uuids = $this->uuidsOf( + $this->get('specialty_id=' . $t['root']->getId() . '&name=' . urlencode($marker)) + ); + + self::assertContains($doctor->getUuid(), $uuids); + } + + public function testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother(): void + { + // تمایزدهنده‌ترین حالت: نام پزشک به عبارت نمی‌خورد، فیلتر روی تخصص الف است و + // عبارت جستجو نامِ تخصص ب. با alias مشترک، یک ردیفِ join باید همزمان + // `id = childA` و `name LIKE childB` باشد — ناممکن، و پزشک بی‌صدا حذف می‌شود. + $t = $this->newTree(); + $doctor = $this->newDoctor('دکتر بدون واژهٔ مشترک ' . uniqid(), [$t['childA'], $t['childB']]); + + $uuids = $this->uuidsOf( + $this->get('specialty_id=' . $t['childA']->getId() . '&name=' . urlencode($t['childB']->getName())) + ); + + self::assertContains($doctor->getUuid(), $uuids); + } + + public function testNameThatMatchesNothingReturnsAnEmptyList(): void + { + $body = $this->get('name=' . urlencode('عبارتیکهوجودنداردناباور' . uniqid())); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertSame([], $body['data']); + self::assertSame(0, $body['meta']['totalRecords']); + } + + // ── مرزی ────────────────────────────────────────────────────────────────── + + public function testUnknownSpecialtyIdReturnsAnEmptyListNotEveryDoctor(): void + { + // اگر گسترش، شناسهٔ ناشناس را حذف کند خروجی `IN ()` می‌شود و فیلتر خنثی. + $this->newDoctor('دکتر بی‌ربط ' . uniqid(), []); + + $body = $this->get('specialty_id=999999999'); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertSame([], $body['data']); + self::assertSame(0, $body['meta']['totalRecords']); + } + + // ── parent_id در پاسخ ───────────────────────────────────────────────────── + + public function testEverySpecialtyInTheResponseCarriesItsParentId(): void + { + $t = $this->newTree(); + $this->newDoctor('دکتر شجره ' . uniqid(), [$t['root'], $t['childA']]); + + $body = $this->get('specialty_id=' . $t['root']->getId()); + $row = $body['data'][0]; + + $byId = []; + foreach ($row['specialties'] as $s) { + self::assertArrayHasKey('parent_id', $s, 'هر تخصص پاسخ باید parent_id داشته باشد'); + $byId[(int) $s['id']] = $s['parent_id']; + } + + self::assertNull($byId[$t['root']->getId()], 'ریشه parent_id ندارد'); + // رشته، هم‌شکل با toDetailArray و با کلید id در همین آرایه. + self::assertSame((string) $t['root']->getId(), $byId[$t['childA']->getId()]); + } + + public function testParentIdAddsNoQueryPerSpecialty(): void + { + // این لیست یک N+1 **از قبل موجود** دارد: findWithFilters مجموعهٔ تخصص‌ها را + // fetch-join نمی‌کند، پس هر پزشک یک کوئری برای بارگذاری collection می‌خورد. + // اندازه‌گیری شد: با ۴ پزشک بیشتر، ۶ → ۱۱ کوئری — چه با parent_id چه بدون آن. + // + // آنچه اینجا تثبیت می‌شود این است که parent_id چیزی به آن اضافه **نمی‌کند**: + // خواندن شناسه از proxy والد کوئری نمی‌زند، چون شناسه از قبل معلوم است. + // اگر می‌زد، رشد به‌ازای هر پزشک به تعداد تخصص‌هایش (اینجا ۳ برابر) می‌شد. + // + // بدون disableReboot، kernel در هر درخواست ری‌بوت می‌شود و شمارندهٔ مشترک + // صفر برمی‌گردد — همان الگوی ClinicDoctorListNPlusOneTest. + $this->client->disableReboot(); + + $t = $this->newTree(); + for ($i = 0; $i < 6; $i++) { + $this->newDoctor('دکتر شمارش ' . uniqid(), [$t['root'], $t['childA'], $t['childB']]); + } + + $qSmall = $this->countQueries(fn() => $this->get('specialty_id=' . $t['root']->getId() . '&limit=2')); + $qLarge = $this->countQueries(fn() => $this->get('specialty_id=' . $t['root']->getId() . '&limit=6')); + + $extraDoctors = 4; + self::assertLessThanOrEqual( + $qSmall + $extraDoctors + 1, + $qLarge, + sprintf( + 'رشد کوئری از حدِ «یکی به‌ازای هر پزشک» گذشت (%d → %d) — یعنی parent_id هم کوئری می‌زند', + $qSmall, + $qLarge, + ), + ); + } + + public function testTotalRecordsDoesNotDoubleCountAMultiSpecialtyDoctor(): void + { + $t = $this->newTree(); + $doctor = $this->newDoctor('دکتر چندتخصصی ' . uniqid(), [$t['root'], $t['childA'], $t['childB']]); + + $body = $this->get('specialty_id=' . $t['root']->getId()); + + self::assertSame(1, $body['meta']['totalRecords'], 'join چندبه‌چند نباید پزشک را چند بار بشمارد'); + self::assertSame([$doctor->getUuid()], $this->uuidsOf($body)); + } +} diff --git a/tests/Specialty/SpecialtyDescendantsTest.php b/tests/Specialty/SpecialtyDescendantsTest.php new file mode 100644 index 00000000..5c8c5741 --- /dev/null +++ b/tests/Specialty/SpecialtyDescendantsTest.php @@ -0,0 +1,104 @@ +em->getRepository(Specialty::class); + } + + /** @return array{0: Specialty, 1: Specialty, 2: Specialty} ریشه و دو فرزندش */ + private function newTree(): array + { + $suffix = uniqid(); + $root = new Specialty('ریشهٔ ' . $suffix, 'root-' . $suffix); + $this->em->persist($root); + $this->em->flush(); + + $childA = new Specialty('فرزند الف ' . $suffix, 'child-a-' . $suffix, $root); + $childB = new Specialty('فرزند ب ' . $suffix, 'child-b-' . $suffix, $root); + $this->em->persist($childA); + $this->em->persist($childB); + $this->em->flush(); + + return [$root, $childA, $childB]; + } + + public function testRootExpandsToItselfAndEveryChild(): void + { + [$root, $childA, $childB] = $this->newTree(); + + $out = $this->repo()->expandWithDescendants([$root->getId()]); + + self::assertContains($root->getId(), $out); + self::assertContains($childA->getId(), $out); + self::assertContains($childB->getId(), $out); + } + + public function testLeafExpandsToItselfOnly(): void + { + [$root, $childA, $childB] = $this->newTree(); + + $out = $this->repo()->expandWithDescendants([$childA->getId()]); + + self::assertSame([$childA->getId()], $out); + self::assertNotContains($root->getId(), $out); + self::assertNotContains($childB->getId(), $out); + } + + public function testUnknownIdIsKeptSoTheFilterMatchesNothing(): void + { + // حذفش می‌کرد، خروجی خالی می‌شد و `IN ()` فیلتر را خنثی می‌کرد. + self::assertSame([999_999_999], $this->repo()->expandWithDescendants([999_999_999])); + } + + public function testEmptyInputGivesEmptyOutput(): void + { + self::assertSame([], $this->repo()->expandWithDescendants([])); + } + + public function testDuplicateAndStringIdsAreNormalised(): void + { + [$root] = $this->newTree(); + $id = $root->getId(); + + $out = $this->repo()->expandWithDescendants([$id, (string) $id, $id]); + + self::assertSame(count($out), count(array_unique($out))); + self::assertContains($id, $out); + } + + public function testOutputIsSortedAscending(): void + { + [$root] = $this->newTree(); + + $out = $this->repo()->expandWithDescendants([$root->getId()]); + $sorted = $out; + sort($sorted); + + self::assertSame($sorted, $out); + } + + public function testAncestorExpansionIsUnaffected(): void + { + // دو تابع دو جهت‌اند؛ این تست تثبیت می‌کند که قرینهٔ قدیمی دست‌نخورده مانده. + [$root, $childA] = $this->newTree(); + + $out = $this->repo()->expandWithAncestors([$childA->getId()]); + + self::assertContains($childA->getId(), $out); + self::assertContains($root->getId(), $out); + } +}