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:
hamed
2026-06-16 22:27:27 +03:30
co-authored by Claude Opus 4.8
parent 28011160a2
commit 404dd03247
5 changed files with 205 additions and 1 deletions
+116
View File
@@ -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` راستی‌آزمایی کن.