diff --git a/.claude/prompt/fix-doctor-city-via-clinic.md b/.claude/prompt/fix-doctor-city-via-clinic.md new file mode 100644 index 00000000..903086bc --- /dev/null +++ b/.claude/prompt/fix-doctor-city-via-clinic.md @@ -0,0 +1,129 @@ +# فیکس نمایش دکتر در شهر از طریق کلینیک + +## پروژه + +`clinicpro` (backend) + +## زمینه + +دکتری که هیچ آدرس و city مستقیمی ندارد اما به یک کلینیک وصل است، در سایت عمومی (`nobat724_front`) در شهر آن کلینیک نمایش داده نمی‌شود. علت: query فیلتر city فقط روی `d.cities` (جدول `doctor_cities`) کار می‌کند و `Clinic.cityId` را نادیده می‌گیرد. + +## مشکل + +`DoctorRepository::findWithFilters()` فیلتر city: + +```php +// clinicpro/src/Doctor/Repository/DoctorRepository.php خط ۵۶-۵۸ +if (!empty($filters['city'])) { + $qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']); +} +``` + +`ci` از `leftJoin('d.cities', 'ci')` می‌آید — جدول `doctor_cities`. اگر دکتر در این جدول ردیفی نداشته باشد (یعنی هیچ city مستقیم ندارد)، حتی اگر عضو کلینیکی با همان city باشد، از نتایج حذف می‌شود. + +## ساختار مرتبط + +```php +// Doctor Entity +#[ORM\ManyToMany(targetEntity: City::class)] +#[ORM\JoinTable(name: 'doctor_cities')] +private Collection $cities; // جدول doctor_cities + +// Clinic Entity +#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)] +private ?int $cityId = null; // FK به categories.id + +#[ORM\ManyToMany(targetEntity: Doctor::class)] +#[ORM\JoinTable(name: 'clinic_doctors')] +private Collection $doctors; // جدول clinic_doctors +``` + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — باید تغییر کند | +| `clinicpro/src/Doctor/Controller/DoctorController.php` | صدا زننده `findWithFilters` | +| `clinicpro/src/Clinic/Entity/Clinic.php` | `cityId` و `doctors` collection | +| `clinicpro/src/Doctor/Entity/Doctor.php` | `cities` collection | + +## وضعیت فعلی + +```php +// DoctorRepository.php — findWithFilters() +$qb = $this->createQueryBuilder('d') + ->leftJoin('d.specialties', 's') + ->leftJoin('d.provinces', 'pr') + ->leftJoin('d.cities', 'ci') + ->distinct(); + +// ... +if (!empty($filters['city'])) { + $qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']); +} +``` + +## وظایف + +### ۱. گسترش فیلتر city به clinic city + +در `findWithFilters()` join کلینیک را اضافه کن و شرط city را تغییر بده تا هم `doctor_cities` هم `clinic.city_id` را پوشش دهد: + +```php +$qb = $this->createQueryBuilder('d') + ->leftJoin('d.specialties', 's') + ->leftJoin('d.provinces', 'pr') + ->leftJoin('d.cities', 'ci') + ->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') // ← اضافه + ->distinct(); + +// ... +if (!empty($filters['city'])) { + $qb->andWhere('ci.id = :city OR cl.cityId = :city') + ->setParameter('city', (int) $filters['city']); +} +``` + +همچنین فیلتر `state` را هم بررسی کن — اگر `d.provinces` هم خالی باشد، باید از استان کلینیک گرفته شود. اما برای این باگ، city اولویت دارد. + +### ۲. بررسی فیلتر state مشابه + +```php +// فعلی: +if (!empty($filters['state'])) { + $qb->andWhere('pr.id = :state')->setParameter('state', (int) $filters['state']); +} + +// باید: +// اگر کلینیک دارد، province آن کلینیک را هم در نظر بگیر +// اما Clinic فقط cityId دارد، نه provinceId مستقیم +// → از category join برای گرفتن province شهر کلینیک استفاده کن یا فعلاً فقط city را فیکس کن +``` + +### ۳. import لازم + +در بالای `DoctorRepository.php` اضافه کن: + +```php +use App\Clinic\Entity\Clinic; +use Doctrine\ORM\Query\Expr\Join; +``` + +(اگر `Join` از قبل import نشده) + +### ۴. تست + +```bash +ddev exec php bin/console debug:router | grep "api/v1/doctors" +# endpoint: GET /api/v1/doctors?city= +# تست: یک دکتر که فقط از طریق کلینیک در شهر X است را با ?city=X جستجو کن +# باید در نتایج ظاهر شود +``` + +## نکات مهم + +- `Clinic.cityId` یک integer FK است به `categories.id` (جدول categories با bundle='city') — نه یک relation object. پس `cl.cityId = :city` درست است (نه `cl.city.id`). +- `leftJoin` برای کلینیک لازم است نه `innerJoin` — دکتری که اصلاً عضو هیچ کلینیکی نیست هم باید در نتایج بماند (اگر city مستقیم دارد). +- `OR cl.cityId = :city` ممکن است با `DISTINCT` مشکل ایجاد کند — اگر دکتر هم city مستقیم دارد هم عضو کلینیک در همان city است، `DISTINCT` آن را یک‌بار نمایش می‌دهد. +- migration لازم نیست — فقط query تغییر می‌کند. +- بعد از تغییر، `docs/api/doctor.md` را update کن (رفتار فیلتر city تغییر کرده). diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 6b9f066e..1fc5d292 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon, BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon, - PhotoIcon, XMarkIcon, + PhotoIcon, XMarkIcon, ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -115,11 +115,48 @@ function SBadge({ status }: { status: number }) { ); } +// ── Export helper ────────────────────────────────────────────────────────────── + +async function exportJson(url: string, filename: string, token: string | null) { + const res = await fetch(url, token ? { headers: { Authorization: `Bearer ${token}` } } : {}); + const json = await res.json(); + const items = json?.data ?? json?.data?.data ?? []; + const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +} + // ── Tab sub-component wrapper ────────────────────────────────────────────────── -function TabActions({ label, onClick }: { label: string; onClick: () => void }) { +function TabActions({ label, onClick, exportUrl, exportFile }: { + label: string; + onClick: () => void; + exportUrl: string; + exportFile: string; +}) { + const token = useAuthStore((s) => s.token); + const [exporting, setExporting] = useState(false); + + const handleExport = async () => { + setExporting(true); + try { + await exportJson(exportUrl, exportFile, token); + } catch { + // silent + } finally { + setExporting(false); + } + }; + return ( -
+
+ @@ -191,7 +228,7 @@ function ProvincesTab() { return ( <> - { reset({ status: '1', weight: '0' }); setAddOpen(true); }} /> + { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/provinces?limit=9999" exportFile="state.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در استان‌ها..." emptyMessage="هیچ استانی یافت نشد" actions={(p) => ( <> @@ -351,7 +388,7 @@ function CitiesTab() { return ( <> - { reset({ status: '1', weight: '0' }); setAddOpen(true); }} /> + { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/cities?limit=9999" exportFile="city.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد" actions={(c) => ( <> @@ -511,7 +548,7 @@ function SpecialtiesTab() { return ( <> - { reset({ status: '1', weight: '0' }); setAddOpen(true); }} /> + { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/specialties?limit=9999" exportFile="specialties.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصص‌ها..." emptyMessage="هیچ تخصصی یافت نشد" actions={(s) => ( <> @@ -632,7 +669,7 @@ function DoctorServicesTab() { return ( <> - { reset({ status: '1', weight: '0' }); setAddOpen(true); }} /> + { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/doctor-services?limit=9999" exportFile="doctor-services.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد" actions={(s) => ( <> @@ -758,7 +795,7 @@ function InsurancesTab() { return ( <> - { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} /> + { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} exportUrl="/api/v1/admin/insurances?limit=9999" exportFile="insurances.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمه‌ها..." emptyMessage="هیچ بیمه‌ای یافت نشد" actions={(i) => ( <> @@ -872,7 +909,7 @@ function TagsTab() { return ( <> - { reset({ status: '1' }); setAddOpen(true); }} /> + { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/tags?limit=9999" exportFile="tags.json" /> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگ‌ها..." emptyMessage="هیچ تگی یافت نشد" actions={(t) => ( <> diff --git a/docs/api/doctor.md b/docs/api/doctor.md index d4974eed..04cb2983 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -178,9 +178,9 @@ List doctors with pagination and filters. | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 20 | | `search` | string | ❌ | Search in title | -| `specialty_id` | integer | ❌ | Filter by specialty | -| `city_id` | integer | ❌ | Filter by city | -| `state_id` | integer | ❌ | Filter by province | +| `specialty` | integer | ❌ | Filter by specialty ID | +| `city` | integer | ❌ | Filter by city ID — شامل دکترهایی که مستقیم در آن شهر هستند (`doctor_cities`) یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) | +| `state` | integer | ❌ | Filter by province ID | ### Response `200` ```json diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index c63c21c7..131ba280 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -5,6 +5,7 @@ namespace App\Doctor\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; @@ -48,13 +49,17 @@ class DoctorRepository extends ServiceEntityRepository ->leftJoin('d.specialties', 's') ->leftJoin('d.provinces', 'pr') ->leftJoin('d.cities', 'ci') + ->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') + ->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') ->distinct(); if (!empty($filters['state'])) { - $qb->andWhere('pr.id = :state')->setParameter('state', (int) $filters['state']); + $qb->andWhere('pr.id = :state OR IDENTITY(ca.province) = :state') + ->setParameter('state', (int) $filters['state']); } if (!empty($filters['city'])) { - $qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']); + $qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city') + ->setParameter('city', (int) $filters['city']); } if (!empty($filters['specialty'])) { $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);