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:
@@ -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
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user