# اصلاح فیلتر شهر/استان در لیست عمومی پزشکان (`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` استفاده شده) — دوباره اضافه نکن.
