From 404dd03247dc28baba26b496fe47dfe8ac3cf29c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 16 Jun 2026 22:27:27 +0330 Subject: [PATCH] feat(specialty): public doctor-counts endpoint (per city) Add GET /api/v1/specialties/doctor-counts?city_id= returning every active specialty with number_of_doctors (distinct doctors via doctor_specialties, scoped by doctor_cities when city_id is given). Make /api/v1/specialties GET public. Powers the /specialties page count. Docs updated. Co-Authored-By: Claude Opus 4.8 --- .claude/prompt/specialty-doctor-counts.md | 116 ++++++++++++++++++ config/packages/security.yaml | 3 +- docs/api/specialty.md | 33 +++++ .../Controller/SpecialtyController.php | 25 ++++ .../Repository/SpecialtyRepository.php | 29 +++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 .claude/prompt/specialty-doctor-counts.md diff --git a/.claude/prompt/specialty-doctor-counts.md b/.claude/prompt/specialty-doctor-counts.md new file mode 100644 index 00000000..f99f5809 --- /dev/null +++ b/.claude/prompt/specialty-doctor-counts.md @@ -0,0 +1,116 @@ +# تعداد پزشکان هر تخصص (به تفکیک شهر) + +## پروژه + +`clinicpro` (Backend — منبع حقیقت). + +> **Cross-repo:** صفحه‌ی عمومی `/specialties` در `nobat724_front` این داده را مصرف می‌کند تا زیر هر تخصص «N پزشک» نشان دهد (پرامپت همتا: `nobat724_front/.claude/prompt/specialties-doctor-count-wire.md`). این پرامپت **اول** اجرا شود. + +## زمینه + +صفحه‌ی `/specialties` در سایت عمومی، زیر هر کارت تخصص می‌خواهد تعداد پزشکان آن تخصص را نشان دهد (`{number_of_doctors} پزشک`). اما هیچ endpointی این تعداد را نمی‌دهد؛ `GET /api/v1/specialties` فقط `{id, uuid, name, slug, status, weight, parent_id}` برمی‌گرداند. سایت چند-شهری است و شمارش باید **فقط پزشکانِ شهرِ دامنه‌ی جاری** باشد. + +داده‌ی موجود: join table `doctor_specialties` (پزشک↔تخصص) و `doctor_cities` (پزشک↔شهر). `Doctor::getSpecialties()` و `getCities()` روابط ManyToMany‌اند. endpoint `GET /api/v1/doctors` از قبل پارامترهای `specialty_id` و `city_id` را می‌پذیرد (الگوی فیلتر شهر). + +## مشکل / هدف + +یک endpoint عمومی که برای هر تخصصِ فعال، تعداد پزشکانِ آن تخصص را در یک شهر مشخص (`city_id`) برگرداند — در یک فراخوانی (نه N درخواست). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Specialty/Controller/SpecialtyController.php` | افزودن endpoint `doctorCounts` | +| `src/Specialty/Repository/SpecialtyRepository.php` | متد شمارش پزشک per specialty با فیلتر شهر | +| `src/Doctor/Entity/Doctor.php` | روابط `specialties` (doctor_specialties) و `cities` (doctor_cities) — مرجع | +| `config/packages/security.yaml` | endpoint جدید زیر `public_endpoints` (مثل `specialties`) | +| `docs/api/specialty.md` | مستندسازی | + +## وضعیت فعلی (کد واقعی) + +`SpecialtyController::list` (مرجع سبک): +```php +#[Route('/api/v1/specialties', methods: ['GET'])] +public function list(Request $request): JsonResponse +{ + $parentId = $request->query->get('parent_id'); + $items = array_map(fn(Specialty $s) => $s->toArray(), + $this->repo->findActive($parentId !== null ? (int)$parentId : null)); + return $this->success(['data' => $items]); +} +``` + +`Specialty::toArray`: `{id, uuid, name, slug, status, weight, parent_id}` — بدون تعداد پزشک. + +`Doctor`: +```php +#[ORM\ManyToMany(targetEntity: Specialty::class)] +#[ORM\JoinTable(name: 'doctor_specialties', ...)] +private Collection $specialties; + +#[ORM\ManyToMany(targetEntity: City::class)] // doctor_cities +private Collection $cities; +``` + +## وظایف + +### ۱. متد شمارش در `SpecialtyRepository` +متدی که با یک کوئری، نگاشت `specialty_id => count(distinct doctor)` را برای پزشکانِ یک شهر برگرداند: +```php +/** @return array specialtyId => doctorCount (within a city if given) */ +public function doctorCountsByCity(?int $cityId): array +{ + $qb = $this->getEntityManager()->createQueryBuilder() + ->select('s.id AS specialty_id', 'COUNT(DISTINCT d.id) AS cnt') + ->from(\App\Doctor\Entity\Doctor::class, 'd') + ->join('d.specialties', 's') + ->groupBy('s.id'); + + if ($cityId !== null) { + $qb->join('d.cities', 'c')->andWhere('c.id = :city')->setParameter('city', $cityId); + } + + // در صورت وجود فیلد وضعیت پزشک (فعال/تأییدشده) آن را هم اعمال کن تا با لیست عمومی doctors هم‌خوان باشد + $rows = $qb->getQuery()->getArrayResult(); + $map = []; + foreach ($rows as $r) { $map[(int)$r['specialty_id']] = (int)$r['cnt']; } + return $map; +} +``` +> اگر `Doctor` فیلد وضعیت/visibility دارد (مثل آنچه `GET /doctors` برای لیست عمومی فیلتر می‌کند)، **همان شرط** را اینجا هم بگذار تا تعداد با نتیجه‌ی واقعیِ `/doctors?specialty_id=&city_id=` یکی باشد. الگوی فیلتر را از `DoctorController::list` بردار. + +### ۲. endpoint `doctorCounts` در `SpecialtyController` +```php +#[Route('/api/v1/specialties/doctor-counts', methods: ['GET'])] +public function doctorCounts(Request $request): JsonResponse +{ + $cityId = $request->query->get('city_id'); + $counts = $this->repo->doctorCountsByCity($cityId !== null ? (int) $cityId : null); + + $items = array_map(function (Specialty $s) use ($counts) { + $arr = $s->toArray(); + $arr['number_of_doctors'] = $counts[$s->getId()] ?? 0; + return $arr; + }, $this->repo->findActive(null)); + + return $this->success(['data' => $items]); +} +``` +- خروجی: همان شکل `specialties` + کلید `number_of_doctors`. +- تخصص‌های بدون پزشک → `0`. + +### ۳. عمومی‌کردن مسیر در `security.yaml` +`specialties` از قبل عمومی است؛ مطمئن شو `/api/v1/specialties/doctor-counts` هم زیر `public_endpoints` می‌افتد (الگوی `api/v1/specialties` معمولاً با prefix پوشش می‌دهد — تأیید کن مسیر جدید عمومی است، وگرنه اضافه کن). + +### ۴. مستندسازی `docs/api/specialty.md` +endpoint جدید: method/path، query `city_id` (اختیاری)، و response با `number_of_doctors`. + +## نکات مهم + +- شمارش `DISTINCT d.id` تا اگر پزشک چند شهر/رابطه دارد دوبار شمرده نشود. +- `city_id` همان id شهر در `categories` (bundle=city) است که فرانت از `getStateInfo().matchedCity.id` می‌فرستد. +- اگر `city_id` نیامد، شمارش سراسری برگردد (fallback ایمن). +- فیلتر وضعیت پزشک باید با `GET /doctors` هم‌خوان باشد تا «N پزشک» با لیست واقعی بخواند. +- یک کوئری grouped (نه N+1). +- پاسخ‌ها از `BaseController`؛ migration لازم نیست. +- تست: `GET /api/v1/specialties/doctor-counts?city_id=` → آرایه‌ای از تخصص‌ها که هر کدام `number_of_doctors` دارند؛ مجموع/نمونه را با شمارش مستقیم `doctor_specialties⋈doctor_cities` راستی‌آزمایی کن. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index fb8feb9b..c0386bc6 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -33,7 +33,7 @@ security: provider: api_doc_provider public_endpoints: - pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) + pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) stateless: true security: false @@ -62,6 +62,7 @@ security: - { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS } - { path: ^/api/v1/appointment-settings/month-availability/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/specialties, methods: [GET], roles: PUBLIC_ACCESS } - path: '^/api/v1/rate/[^/]+$' methods: [GET] roles: PUBLIC_ACCESS diff --git a/docs/api/specialty.md b/docs/api/specialty.md index dd250973..3edd3171 100644 --- a/docs/api/specialty.md +++ b/docs/api/specialty.md @@ -42,6 +42,39 @@ List all medical specialties. --- +## GET `/api/v1/specialties/doctor-counts` + +List active specialties together with the number of doctors in each — optionally scoped to a city. Used by the public `/specialties` page to show «N پزشک» under each specialty. + +**Permission:** `PUBLIC` + +### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `city_id` | integer | ❌ | Count only doctors in this city (the `categories.id` of the city). Omit for a global count. | + +### Response `200` +```json +{ + "success": true, + "data": [ + { + "id": 2, + "name": "داخلی عمومی", + "slug": "...", + "parent_id": null, + "status": "active", + "weight": 10, + "number_of_doctors": 10 + } + ] +} +``` +- Returns **all** active specialties; those with no doctors have `number_of_doctors: 0`. +- `number_of_doctors` counts distinct doctors joined through `doctor_specialties` (and `doctor_cities` when `city_id` is given), matching the public `GET /api/v1/doctors?specialty_id=&city_id=` filter. + +--- + ## GET `/api/v1/admin/specialties` List all specialties with pagination (admin view — includes inactive). diff --git a/src/Specialty/Controller/SpecialtyController.php b/src/Specialty/Controller/SpecialtyController.php index da8f4c4e..a6193e60 100644 --- a/src/Specialty/Controller/SpecialtyController.php +++ b/src/Specialty/Controller/SpecialtyController.php @@ -30,6 +30,31 @@ class SpecialtyController extends BaseController return $this->success(['data' => $items]); } + #[OA\Get( + path: '/api/v1/specialties/doctor-counts', + summary: 'Active specialties with the number of doctors (optionally scoped to a city)', + parameters: [ + new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Specialties with number_of_doctors'), + ] + )] + #[Route('/api/v1/specialties/doctor-counts', methods: ['GET'])] + public function doctorCounts(Request $request): JsonResponse + { + $cityId = $request->query->get('city_id'); + $counts = $this->repo->doctorCountsByCity($cityId !== null ? (int) $cityId : null); + + $items = array_map(function (Specialty $s) use ($counts) { + $arr = $s->toArray(); + $arr['number_of_doctors'] = $counts[$s->getId()] ?? 0; + return $arr; + }, $this->repo->findActive(null)); + + return $this->success(['data' => $items]); + } + // ── Admin CRUD ──────────────────────────────────────────────────────────── #[Route('/api/v1/admin/specialty', methods: ['POST'])] diff --git a/src/Specialty/Repository/SpecialtyRepository.php b/src/Specialty/Repository/SpecialtyRepository.php index 76b97898..6524f455 100644 --- a/src/Specialty/Repository/SpecialtyRepository.php +++ b/src/Specialty/Repository/SpecialtyRepository.php @@ -2,6 +2,7 @@ namespace App\Specialty\Repository; +use App\Doctor\Entity\Doctor; use App\Specialty\Entity\Specialty; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -28,6 +29,34 @@ class SpecialtyRepository extends ServiceEntityRepository return $qb->getQuery()->getResult(); } + /** + * Count distinct doctors per specialty, optionally limited to a city. + * Mirrors the join filters used by the public doctor list (cities/specialties). + * + * @return array specialtyId => doctorCount + */ + public function doctorCountsByCity(?int $cityId): array + { + $qb = $this->getEntityManager()->createQueryBuilder() + ->select('s.id AS specialty_id', 'COUNT(DISTINCT d.id) AS cnt') + ->from(Doctor::class, 'd') + ->join('d.specialties', 's') + ->groupBy('s.id'); + + if ($cityId !== null) { + $qb->join('d.cities', 'c') + ->andWhere('c.id = :city') + ->setParameter('city', $cityId); + } + + $map = []; + foreach ($qb->getQuery()->getArrayResult() as $row) { + $map[(int) $row['specialty_id']] = (int) $row['cnt']; + } + + return $map; + } + public function findBySlug(string $slug): ?Specialty { return $this->findOneBy(['slug' => $slug]);