fix(doctors): correct city/state filters in doctor listing API

This commit is contained in:
hamed
2026-07-02 11:13:16 +03:30
parent fb90e447d8
commit 4565f521c4
4 changed files with 196 additions and 18 deletions
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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(
+15 -12
View File
@@ -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']);