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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<int,int> 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=<id>` → آرایهای از تخصصها که هر کدام `number_of_doctors` دارند؛ مجموع/نمونه را با شمارش مستقیم `doctor_specialties⋈doctor_cities` راستیآزمایی کن.
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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<int,int> 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]);
|
||||
|
||||
Reference in New Issue
Block a user