# فیلتر کلینیک بر اساس شهر/استانِ آدرس کلینیک ## پروژه `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=` همچنان کار کند. - حتماً در محیط **prod** هم تست کن (`cache:clear --env=prod` سپس درخواست از `https://clinic-pro.ddev.site`) چون باگ قبلی فقط در prod ظاهر شد.