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:
+8
-4
@@ -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`).
|
||||
|
||||
+33
-19
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user