feat(clinic): update filtering logic to use clinic address for city/state

This commit is contained in:
hamed
2026-06-18 09:06:36 +03:30
parent 404dd03247
commit fa41c15bb6
3 changed files with 135 additions and 6 deletions
@@ -0,0 +1,116 @@
# فیلتر کلینیک بر اساس شهر/استانِ آدرس کلینیک
## پروژه
`clinicpro` (Backend). تغییر فقط منطق داخلی است؛ قرارداد API (`GET /api/v1/clinics?city=&state=`) و مصرف `nobat724_front` تغییر نمی‌کند — نیازی به پرامپت همتای فرانت نیست.
## زمینه
معماری مکانِ کلینیک عوض شده: قبلاً شهر/استان روی خودِ `Clinic` (ستون‌های `city_id`/`province_id`) ذخیره می‌شد، اما حالا این ستون‌ها عملاً پر نمی‌شوند و **شهر/استان واقعی روی آدرس کلینیک** است — یعنی رکورد `DoctorAddress` با `clinic_id = clinic.id`، که `city_id`/`province_id` خودش را دارد.
شواهد DB:
- `clinics`: تنها رکوردها `city_id`/`province_id` ندارند (یا قدیمی‌اند). فیلتر روی `c.cityId`/`c.provinceId` هیچ نتیجه‌ای نمی‌دهد.
- `doctor_addresses` با `type='clinic'` و `clinic_id` پر: مثلاً `clinic_id=226 → city_id=132 (یزد), province_id=100`، `clinic_id=225 → city_id=101 (تبریز), province_id=1`.
در نتیجه `GET /api/v1/clinics?city=132&state=100` (یزد) صفر کلینیک برمی‌گرداند، هرچند کلینیک ۲۲۶ در یزد است.
## مشکل / هدف
`ClinicRepository::findWithFilters` باید فیلتر `city`/`state` را از طریق **آدرس کلینیک** (`DoctorAddress` با `clinic_id = clinic.id`) اعمال کند، نه ستون‌های منسوخِ `c.cityId`/`c.provinceId`.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Clinic/Repository/ClinicRepository.php` | `findWithFilters` — فیلتر مکان |
| `src/Doctor/Entity/DoctorAddress.php` | حامل مکان: `clinicId` (scalar)، `city` (ManyToOne City)، `province` (ManyToOne Province) |
| `src/Clinic/Entity/Clinic.php` | `cityId`/`provinceId` منسوخ (دیگر مبنای فیلتر نیست) |
## وضعیت فعلی (کد واقعی)
`ClinicRepository::findWithFilters` (مکان روی خود Clinic — نادرست):
```php
$qb = $this->createQueryBuilder('c')
->leftJoin('c.specialties', 's')
->distinct();
if (!empty($filters['state'])) {
$qb->andWhere('c.provinceId = :state')->setParameter('state', (int) $filters['state']);
}
if (!empty($filters['city'])) {
$qb->andWhere('c.cityId = :city')->setParameter('city', (int) $filters['city']);
}
if (!empty($filters['specialty'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
}
$qb->orderBy('c.id', $sort);
```
`DoctorAddress` (مرجع — مکان واقعی):
```php
#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)]
private ?int $clinicId = null; // scalar (نه رابطه‌ی Doctrine به Clinic)
#[ORM\ManyToOne(targetEntity: City::class)]
#[ORM\JoinColumn(name: 'city_id', ...)]
private ?City $city = null;
#[ORM\ManyToOne(targetEntity: Province::class)]
#[ORM\JoinColumn(name: 'province_id', ...)]
private ?Province $province = null;
```
> چون رابطه‌ی Doctrine بین `Clinic` و `DoctorAddress` وجود ندارد (فقط scalar `clinicId`)، باید با یک join شرط‌دار (`WITH addr.clinicId = c.id`) join کرد.
## وظایف
### ۱. join به آدرس کلینیک و فیلتر روی city/province آدرس
`c.provinceId`/`c.cityId` را با join به `DoctorAddress` (روی `clinic_id`) و فیلتر `addr.city`/`addr.province` جایگزین کن. join فقط وقتی لازم است که `city` یا `state` در فیلترها باشد:
```php
$qb = $this->createQueryBuilder('c')
->leftJoin('c.specialties', 's')
->distinct();
if (!empty($filters['city']) || !empty($filters['state'])) {
// DoctorAddress رابطه‌ی Doctrine به Clinic ندارد؛ با شرط clinicId join می‌کنیم
$qb->join(
\App\Doctor\Entity\DoctorAddress::class,
'addr',
\Doctrine\ORM\Query\Expr\Join::WITH,
'addr.clinicId = c.id'
);
if (!empty($filters['city'])) {
$qb->andWhere('IDENTITY(addr.city) = :city')
->setParameter('city', (int) $filters['city']);
}
if (!empty($filters['state'])) {
$qb->andWhere('IDENTITY(addr.province) = :state')
->setParameter('state', (int) $filters['state']);
}
}
if (!empty($filters['specialty'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
}
$qb->orderBy('c.id', $sort);
```
- `IDENTITY(addr.city)` کلید خارجی (city_id) را بدون join اضافی به جدول `categories` می‌گیرد — سبک و دقیق.
- `distinct()` حفظ شود (یک کلینیک ممکن است چند آدرس داشته باشد).
- `Paginator` موجود روی همین `$qb` کار می‌کند؛ به‌خاطر join شرط‌دار، `distinct` باید فعال بماند تا شمارش total درست باشد.
### ۲. مستندسازی `docs/api/clinic.md`
در بخش `GET /api/v1/clinics`، یک جمله اضافه کن که فیلتر `city`/`state` بر اساس **شهر/استانِ آدرس کلینیک** اعمال می‌شود (نه فیلد مستقیم روی کلینیک).
## نکات مهم
- این رفع، باگِ `Class Clinic has no field stateId` (که قبلاً به `provinceId` تغییر یافت) را هم به‌طور ریشه‌ای حل می‌کند: مکان دیگر از خود Clinic خوانده نمی‌شود.
- `DoctorAddress.clinicId` یک ستون scalar است (نه association)، پس join باید با `Join::WITH 'addr.clinicId = c.id'` باشد، نه `c.addresses`.
- ستون‌های `c.cityId`/`c.provinceId` را دست نزن (ممکن است جای دیگری استفاده شوند)؛ فقط مبنای فیلتر را عوض کن.
- پاسخ‌ها از `BaseController` (`paginated`)؛ migration لازم نیست.
- تست رفتاری (از داخل container و هاست):
- `GET /api/v1/clinics?city=132&state=100` (یزد) → باید کلینیک ۲۲۶ را برگرداند (قبلاً صفر بود).
- `GET /api/v1/clinics?city=101&state=1` (تبریز) → کلینیک ۲۲۵.
- `GET /api/v1/clinics` بدون فیلتر → همه‌ی کلینیک‌ها (join اعمال نشود).
- `?specialty=<id>` همچنان کار کند.
- حتماً در محیط **prod** هم تست کن (`cache:clear --env=prod` سپس درخواست از `https://clinic-pro.ddev.site`) چون باگ قبلی فقط در prod ظاهر شد.
+5 -1
View File
@@ -180,7 +180,11 @@ List clinics with pagination.
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `name` | string | ❌ | Search by clinic name |
| `city` | string | ❌ | Filter by city |
| `city` | integer | ❌ | City id — filters by the **clinic address's** city |
| `state` | integer | ❌ | Province id — filters by the **clinic address's** province |
| `specialty` | integer | ❌ | Specialty id |
> `city`/`state` are matched against the clinic's address (`DoctorAddress` linked by `clinic_id`), not a field on the clinic itself.
### Response `200`
```json
+14 -5
View File
@@ -5,7 +5,9 @@ namespace App\Clinic\Repository;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;
@@ -47,11 +49,18 @@ class ClinicRepository extends ServiceEntityRepository
->leftJoin('c.specialties', 's')
->distinct();
if (!empty($filters['state'])) {
$qb->andWhere('c.stateId = :state')->setParameter('state', (int) $filters['state']);
}
if (!empty($filters['city'])) {
$qb->andWhere('c.cityId = :city')->setParameter('city', (int) $filters['city']);
// City/province live on the clinic's address (DoctorAddress.clinicId),
// not on the clinic itself. DoctorAddress has no Doctrine relation to
// Clinic, so join on the scalar clinicId with a WITH condition.
if (!empty($filters['city']) || !empty($filters['state'])) {
$qb->join(DoctorAddress::class, 'addr', Join::WITH, 'addr.clinicId = c.id');
if (!empty($filters['city'])) {
$qb->andWhere('IDENTITY(addr.city) = :city')->setParameter('city', (int) $filters['city']);
}
if (!empty($filters['state'])) {
$qb->andWhere('IDENTITY(addr.province) = :state')->setParameter('state', (int) $filters['state']);
}
}
if (!empty($filters['specialty'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);