fix(doctors): correct city/state filters in doctor listing API
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# اصلاح فیلتر شهر/استان در لیست عمومی پزشکان (`GET /api/v1/doctors`)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend / API). باگ کاملاً سمت backend است؛ سایت عمومی `nobat724_front` بدون تغییر میماند (توضیح در «نکات مهم»).
|
||||
|
||||
## زمینه
|
||||
|
||||
`GET /api/v1/doctors` لیست عمومی پزشکان با فیلتر شهر/استان/تخصص را برمیگرداند. سایت عمومی این endpoint را با پارامترهای `state`، `city`، `specialty` صدا میزند (از `buildDoctorParams` و `QueryForDoctorsReq` در `nobat724_front/helper/index.js`). اما فیلتر مکان درست کار نمیکند: با `?state=100&city=132` همهٔ پزشکها برمیگردند، نه فقط پزشکهای آن استان/شهر.
|
||||
|
||||
نمونهٔ خرابی که کاربر گزارش کرده: پاسخ شامل `QA Doctor B` با `active: false` هم هست — در حالیکه لیست عمومی پیشفرض باید فقط `active=true` را نشان دهد. یعنی فیلتر `active` هم قربانی همین باگ شده است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه اشکال مستقل در فیلتر لیست پزشکان وجود دارد که باید هر سه رفع شوند:
|
||||
|
||||
1. **پرانتزگذاری اشتباه در شرط `OR`** — بخش شهر/استان یک `OR` را داخل یک `andWhere` بدون پرانتز اضافه میکند. چون در SQL اولویت `AND` بالاتر از `OR` است، کل زنجیرهٔ شرطها (از جمله `active`) میشکند و عملاً «همهٔ پزشکها» برمیگردند. این علت اصلی برگشتن `QA Doctor B` با `active:false` است.
|
||||
2. **منبع اشتباه مکان** — فیلتر روی رابطهٔ ManyToMany `d.provinces` / `d.cities` (جداول `doctor_provinces` / `doctor_cities`) join میزند، ولی پزشکها مکان خود را در **آدرسها** ست میکنند (`DoctorAddress.province` / `DoctorAddress.city`). اگر ادمین فقط آدرس را پر کرده باشد و ManyToMany خالی باشد، `pr.id = :state` هیچوقت match نمیشود.
|
||||
3. **عدم تطابق نام پارامتر** — کد repository کلیدهای `state` / `city` / `specialty` را میخواند، اما مستندات OpenAPI (annotation کنترلر + `docs/api/doctor.md`) و برخی کلاینتها (Swagger، احتمالاً `clinic-pro-tauri`) نامهای `state_id` / `city_id` / `specialty_id` را استفاده میکنند. هر کلاینتی که از نام `_id` استفاده کند، فیلترش بیاثر میشود و همهٔ پزشکها را میگیرد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `clinicpro/src/Doctor/Repository/DoctorRepository.php` | متد `findWithFilters()` — منطق فیلتر معیوب |
|
||||
| `clinicpro/src/Doctor/Controller/DoctorController.php` | route `GET /api/v1/doctors` (خط ۲۴۰) + annotationهای `OA\Parameter` (خط ۲۱۰–۲۱۶) |
|
||||
| `clinicpro/src/Doctor/Entity/Doctor.php` | رابطههای `provinces`/`cities` (ManyToMany) و `addresses` (OneToMany) |
|
||||
| `clinicpro/src/Doctor/Entity/DoctorAddress.php` | `province` / `city` (ManyToOne) + `clinicId` + `doctor` |
|
||||
| `clinicpro/docs/api/doctor.md` | مستندات endpoint (خط ۱۷۳ به بعد) — باید بهروز شود |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
منطق فعلی فیلتر در `DoctorRepository::findWithFilters()`:
|
||||
|
||||
```php
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->leftJoin('d.specialties', 's')
|
||||
->leftJoin('d.provinces', 'pr') // ManyToMany — اغلب خالی
|
||||
->leftJoin('d.cities', 'ci') // ManyToMany — اغلب خالی
|
||||
->distinct();
|
||||
|
||||
if (!empty($filters['state'])) {
|
||||
$stateId = (int) $filters['state'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId);
|
||||
// ❌ بدون پرانتز: 'pr.id = :state OR d.id IN (...)'
|
||||
$qb->andWhere('pr.id = :state' . ($clinicIds ? ' OR d.id IN (:stateClinicDoctorIds)' : ''))
|
||||
->setParameter('state', $stateId);
|
||||
if ($clinicIds) {
|
||||
$qb->setParameter('stateClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['city'])) {
|
||||
$cityId = (int) $filters['city'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('city', $cityId);
|
||||
// ❌ همان مشکل پرانتز
|
||||
$qb->andWhere('ci.id = :city' . ($clinicIds ? ' OR d.id IN (:cityClinicDoctorIds)' : ''))
|
||||
->setParameter('city', $cityId);
|
||||
if ($clinicIds) {
|
||||
$qb->setParameter('cityClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
}
|
||||
// ...
|
||||
$active = isset($filters['active']) ? (bool) $filters['active'] : true;
|
||||
$qb->andWhere('d.activeDoctorAppointment = :active')
|
||||
->setParameter('active', $active);
|
||||
```
|
||||
|
||||
DQL تولیدشده (وقتی state و active هر دو هستند) عملاً این میشود:
|
||||
|
||||
```
|
||||
WHERE pr.id = :state OR d.id IN (:stateClinicDoctorIds) AND d.activeDoctorAppointment = :active
|
||||
```
|
||||
|
||||
که بهخاطر اولویت `AND > OR` تفسیرش این است:
|
||||
|
||||
```
|
||||
WHERE pr.id = :state OR ( d.id IN (:stateClinicDoctorIds) AND d.activeDoctorAppointment = :active )
|
||||
```
|
||||
|
||||
پس هر پزشکی که `pr.id = :state` باشد بدون توجه به `active` برمیگردد و اگر ManyToMany خالی باشد، شاخهٔ `d.id IN (...)` رفتار غیرمنتظره میدهد → «همهٔ پزشکها».
|
||||
|
||||
نکتهٔ مهم دربارهٔ منبع مکان: در `DoctorController::hydrateAddress()` (خط ۸۱۲–۸۱۹) مکان روی **آدرس** ست میشود (`address->setCity` / `address->setProvince`)، در حالیکه ManyToMany فقط وقتی کلیدهای `states`/`cities` در بدنهٔ update بیایند پر میشود (خط ۷۸۱–۷۹۷). یعنی مکان واقعی پزشک در `doctor_addresses` است، نه لزوماً در `doctor_provinces`/`doctor_cities`.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. رفع پرانتزگذاری `OR` (اولویتدار — علت اصلی)
|
||||
|
||||
هر شرط ترکیبی `OR` باید داخل پرانتز به `andWhere` داده شود تا از بقیهٔ شرطهای `AND` (بهویژه `active`) جدا بماند. بهجای الحاق رشته، از `$qb->expr()->orX()` استفاده کن یا رشته را صریحاً داخل پرانتز بگذار:
|
||||
|
||||
```php
|
||||
// نمونه با orX
|
||||
$orX = $qb->expr()->orX('addrProv.id = :state'); // نام join را در وظیفهٔ ۲ نهایی کن
|
||||
if ($clinicIds) {
|
||||
$orX->add('d.id IN (:stateClinicDoctorIds)');
|
||||
$qb->setParameter('stateClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
$qb->andWhere($orX)->setParameter('state', $stateId);
|
||||
```
|
||||
|
||||
بعد از این تغییر، شرط `active` همیشه با `AND` روی کل مجموعه اعمال میشود و `QA Doctor B` (active:false) دیگر در پاسخِ پیشفرض نمیآید.
|
||||
|
||||
### ۲. اصلاح منبع مکان به `DoctorAddress`
|
||||
|
||||
فیلتر شهر/استان باید مکانی که پزشک در **آدرس شخصیاش** ست کرده را match کند، نه صرفاً ManyToMany. به جای `leftJoin('d.provinces')` / `leftJoin('d.cities')`، روی آدرسهای شخصی پزشک join بزن (آدرسی که `doctor = d`)، سپس روی `province`/`city` همان آدرس فیلتر کن. شاخهٔ کلینیک (`doctorIdsViaClinicLocation`) که آدرس کلینیک را پوشش میدهد، دستنخورده باقی بماند.
|
||||
|
||||
```php
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->leftJoin('d.specialties', 's')
|
||||
->leftJoin(
|
||||
DoctorAddress::class, 'da',
|
||||
Join::WITH, 'da.doctor = d' // فقط آدرس شخصی پزشک (doctor IS NOT NULL)
|
||||
)
|
||||
->distinct();
|
||||
|
||||
// state
|
||||
if (!empty($filters['state'])) {
|
||||
$stateId = (int) $filters['state'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId);
|
||||
$orX = $qb->expr()->orX('IDENTITY(da.province) = :state');
|
||||
if ($clinicIds) {
|
||||
$orX->add('d.id IN (:stateClinicDoctorIds)');
|
||||
$qb->setParameter('stateClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
$qb->andWhere($orX)->setParameter('state', $stateId);
|
||||
}
|
||||
// city — به همین شکل با IDENTITY(da.city) و cityClinicDoctorIds
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `IDENTITY(da.province)` بدون join اضافه، همان `province_id` را برمیگرداند.
|
||||
- اگر میخواهی سازگاری با پزشکهایی که مکان را در ManyToMany قدیمی ست کردهاند حفظ شود، میتوانی هر دو منبع را در `orX` بگذاری (`IDENTITY(da.province) = :state OR pr.id = :state`) و join ManyToMany را نگه داری؛ اما اگر ManyToMany در پروژه استفادهٔ فعال ندارد، حذفش تمیزتر است. تصمیم را بر اساس داده واقعی بگیر (کوئری بزن: آیا `doctor_provinces`/`doctor_cities` رکورد دارند؟).
|
||||
- `distinct()` را نگه دار (join آدرس ممکن است چند ردیف بدهد).
|
||||
|
||||
### ۳. پذیرش هر دو نام پارامتر (`state` و `state_id` و ...)
|
||||
|
||||
در ابتدای `findWithFilters()` نامهای `_id` را به نامهای بدون پسوند نگاشت کن تا هم کلاینت فعلی (`state`/`city`/`specialty`) و هم کلاینتهای مبتنی بر مستندات (`state_id`/`city_id`/`specialty_id`) کار کنند:
|
||||
|
||||
```php
|
||||
$filters['state'] ??= $filters['state_id'] ?? null;
|
||||
$filters['city'] ??= $filters['city_id'] ?? null;
|
||||
$filters['specialty'] ??= $filters['specialty_id'] ?? null;
|
||||
```
|
||||
|
||||
(چون شرطها با `!empty()` چک میشوند، مقدار `null` بیاثر است.)
|
||||
|
||||
### ۴. یکسانسازی مستندات و annotation
|
||||
|
||||
- در `DoctorController` annotationهای `OA\Parameter` (خط ۲۱۴–۲۱۶) الان `specialty_id` / `city_id` / `state_id` هستند. آنها را با نامهای canonical (`specialty` / `city` / `state`) هماهنگ کن و در توضیح ذکر کن که alias `_id` هم پذیرفته میشود.
|
||||
- `clinicpro/docs/api/doctor.md` بخش `GET /api/v1/doctors` (خط ۱۷۳ به بعد): جدول پارامترها را اصلاح کن — نام canonical `state`/`city`/`specialty`، پذیرش alias `_id`، و توضیح اینکه فیلتر مکان بر اساس **آدرس شخصی پزشک** (`doctor_addresses`) بهعلاوهٔ آدرس کلینیک است (نه ManyToMany).
|
||||
|
||||
### ۵. تست دستی
|
||||
|
||||
بعد از تغییر، با داده واقعی تست کن:
|
||||
|
||||
```bash
|
||||
# باید فقط پزشکهای استان یزد (active) را بدهد، نه همه
|
||||
curl -s "https://clinic-pro.ddev.site/api/v1/doctors?state=100&page=1&limit=20" | jq '.meta, [.data[].name]'
|
||||
# با نام _id هم باید همان نتیجه را بدهد
|
||||
curl -s "https://clinic-pro.ddev.site/api/v1/doctors?state_id=100&city_id=132&page=1&limit=20" | jq '.meta'
|
||||
# QA Doctor B (active:false) نباید در خروجی پیشفرض باشد
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **علت اصلی «همه برمیگردند» = باگ پرانتز (وظیفهٔ ۱).** حتی اگر منبع مکان را اصلاح نکنی، بدون پرانتز `active` نشت میکند. هر دو باید رفع شوند.
|
||||
- **مدرک باگ active:** در گزارش کاربر `QA Doctor B` با `active:false` برگشته؛ بعد از fix نباید بیاید (مگر `?active=0` صریح داده شود).
|
||||
- کنترلر از `BaseController` ارث میبرد؛ خروجی همچنان با `$this->paginated(...)` است — دست نزن.
|
||||
- تاریخها Unix timestampاند؛ به این تسک ربط ندارد.
|
||||
- Entity تغییر نمیکند → **migration لازم نیست** (فقط منطق کوئری عوض میشود).
|
||||
- **سمت frontend:** `nobat724_front` همین حالا `state`/`city`/`specialty` میفرستد (`helper/index.js` → `buildDoctorParams`، `QueryForDoctorsReq`)، پس بعد از این fix بدون تغییر درست کار میکند. پذیرش alias `_id` (وظیفهٔ ۳) صرفاً برای سازگاری Swagger و `clinic-pro-tauri` است.
|
||||
- طبق قانون پروژه، بعد از تغییر endpoint، `clinicpro/docs/api/doctor.md` باید در همین session بهروز شود (وظیفهٔ ۴).
|
||||
- `import` لازم برای `DoctorAddress` و `Join` در بالای `DoctorRepository.php` از قبل هست (در `doctorIdsViaClinicLocation` استفاده شده) — دوباره اضافه نکن.
|
||||
+3
-3
@@ -182,9 +182,9 @@ List doctors with pagination and filters.
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 20 |
|
||||
| `search` | string | ❌ | Search in title |
|
||||
| `specialty` | integer | ❌ | Filter by specialty ID |
|
||||
| `city` | integer | ❌ | Filter by city ID — شامل دکترهایی که مستقیم در آن شهر هستند (`doctor_cities`) یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) |
|
||||
| `state` | integer | ❌ | Filter by province ID |
|
||||
| `specialty_id` | integer | ❌ | Filter by 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`) یا آدرس کلینیک |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
|
||||
@@ -211,9 +211,9 @@ class DoctorController extends BaseController
|
||||
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, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'state_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID', 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')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
|
||||
@@ -45,32 +45,35 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
$limit = min(50, max(1, (int) ($filters['limit'] ?? 10)));
|
||||
$sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
// Location comes from the doctor's own address (doctor_addresses), plus a
|
||||
// fallback to the clinic address for doctors listed under a clinic.
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->leftJoin('d.specialties', 's')
|
||||
->leftJoin('d.provinces', 'pr')
|
||||
->leftJoin('d.cities', 'ci')
|
||||
->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
|
||||
->distinct();
|
||||
|
||||
if (!empty($filters['state'])) {
|
||||
$stateId = (int) $filters['state'];
|
||||
if (!empty($filters['state_id'])) {
|
||||
$stateId = (int) $filters['state_id'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId);
|
||||
$qb->andWhere('pr.id = :state' . ($clinicIds ? ' OR d.id IN (:stateClinicDoctorIds)' : ''))
|
||||
->setParameter('state', $stateId);
|
||||
$orX = $qb->expr()->orX('IDENTITY(da.province) = :state');
|
||||
if ($clinicIds) {
|
||||
$orX->add('d.id IN (:stateClinicDoctorIds)');
|
||||
$qb->setParameter('stateClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
$qb->andWhere($orX)->setParameter('state', $stateId);
|
||||
}
|
||||
if (!empty($filters['city'])) {
|
||||
$cityId = (int) $filters['city'];
|
||||
if (!empty($filters['city_id'])) {
|
||||
$cityId = (int) $filters['city_id'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('city', $cityId);
|
||||
$qb->andWhere('ci.id = :city' . ($clinicIds ? ' OR d.id IN (:cityClinicDoctorIds)' : ''))
|
||||
->setParameter('city', $cityId);
|
||||
$orX = $qb->expr()->orX('IDENTITY(da.city) = :city');
|
||||
if ($clinicIds) {
|
||||
$orX->add('d.id IN (:cityClinicDoctorIds)');
|
||||
$qb->setParameter('cityClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
$qb->andWhere($orX)->setParameter('city', $cityId);
|
||||
}
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
if (!empty($filters['specialty_id'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
|
||||
}
|
||||
if (!empty($filters['gender'])) {
|
||||
$qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']);
|
||||
|
||||
Reference in New Issue
Block a user