diff --git a/.claude/prompt/fix-doctors-list-location-filter.md b/.claude/prompt/fix-doctors-list-location-filter.md new file mode 100644 index 00000000..f01e627f --- /dev/null +++ b/.claude/prompt/fix-doctors-list-location-filter.md @@ -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` استفاده شده) — دوباره اضافه نکن. diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 2a506829..d88463b5 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -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 diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 5681964f..3bf99a0a 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -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( diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index e73f6664..b991798c 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -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']);