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:
hamed
2026-08-08 16:44:32 +03:30
co-authored by Claude Opus 5
parent 08a344c99d
commit 2da5b5188c
9 changed files with 808 additions and 29 deletions
@@ -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()` یکتا بساز تا اجرای دوم هم سبز بماند.
+8 -4
View File
@@ -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:0013: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`).
+33 -19
View File
@@ -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`
+10 -2
View File
@@ -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(
+7
View File
@@ -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,
+48 -4
View File
@@ -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
@@ -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<int|string> $ids
* @return list<int>
*/
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<int,?int> id => parentId for every specialty */
private function parentMap(): array
{
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Tests\ApiTestCase;
/**
* GET /api/v1/doctors — جستجو روی همهٔ تخصص‌های پزشک.
*
* پزشکان تست عمداً **مستقیم** ساخته می‌شوند، نه از راه اندپوینت: مسیر اندپوینت
* expandWithAncestors را صدا می‌زند و والد را خودکار روی پزشک می‌نشاند، و آن‌وقت
* تستِ «گسترش به فرزندان» چیزی را نمی‌سنجد که ادعا می‌کند.
*/
class DoctorSpecialtySearchTest extends ApiTestCase
{
/** @return array{root: Specialty, childA: Specialty, childB: Specialty} */
private function newTree(): array
{
$suffix = uniqid();
$root = new Specialty('ریشهٔ ' . $suffix, 'sroot-' . $suffix);
$this->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<string> 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));
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Tests\Specialty;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use App\Tests\ApiTestCase;
/**
* SpecialtyRepository::expandWithDescendants — قرینهٔ expandWithAncestors.
*
* تفاوت عمدی با قرینه‌اش: شناسهٔ ناشناس حذف نمی‌شود، چون خروجی مستقیم در یک
* `IN (...)` می‌نشیند و آرایهٔ خالی فیلتر را خنثی می‌کند.
*/
class SpecialtyDescendantsTest extends ApiTestCase
{
private function repo(): SpecialtyRepository
{
return $this->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);
}
}