feat(doctor): expose city/state in public doctor list

The public doctor list had no location field, so multi-domain consumers
could not tell which city domain owns a doctor. nobat724_front's sitemap
worked around this by fetching the list once per city (35 sweeps) and
subtracting, costing ~13s to build the root sitemap.

Location is resolved in bulk by DoctorRepository::findLocationsByDoctors
using the same rule the city_id/state_id filter applies: the doctor's own
address first, falling back to the address of a clinic they belong to.
Without the clinic fallback a doctor could match city_id=X yet report no
city, which would break the sitemap's per-domain partitioning.

city/state are arrays with at most one entry, matching the shape already
used by the doctor detail response and the clinic list. A doctor with no
address reports [] rather than null. Multi-location doctors get a single
primary city, mirroring the canonical rule on the public site.

Also surface the applied page size as meta.limit. Repositories silently
clamp limit to 50, which previously made clients believe pagination had
ended early — this is what truncated the sitemap to 50 doctors.

The clinic doctor-list endpoint gets the same location data so both
endpoints agree.

Location resolution costs at most 2 queries regardless of page size,
asserted directly against the repository rather than through the
endpoint, since the endpoint carries a pre-existing specialties N+1 in
findWithFilters that is unrelated to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-19 08:08:48 +03:30
co-authored by Claude Opus 4.8
parent 20bdc49e89
commit 3363dfbf22
9 changed files with 334 additions and 8 deletions
@@ -119,6 +119,88 @@ class DoctorRepository extends ServiceEntityRepository
*
* @return int[]
*/
/**
* شهر/استان دسته‌ای پزشکان برای پاسخ لیست — دو کوئری ثابت، نه یکی به‌ازای هر پزشک.
*
* همان قاعده‌ای که فیلتر city_id/state_id در findWithFilters اعمال می‌کند اینجا هم
* برقرار است: اول آدرس شخصی خود پزشک، و اگر نداشت آدرس کلینیکی که عضو آن است
* (آدرس کلینیک ردیفی از DoctorAddress با doctor IS NULL و clinicId پرشده است).
* بدون این fallback، پزشکی که فقط از طریق کلینیک مکان دارد در فیلتر city_id
* می‌آمد ولی در پاسخ شهرش خالی بود.
*
* @param Doctor[] $doctors
* @return array<int, array{city: ?array, province: ?array}>
*/
public function findLocationsByDoctors(array $doctors): array
{
$ids = array_values(array_filter(array_map(fn(Doctor $d) => $d->getId(), $doctors)));
if (!$ids) {
return [];
}
$locationFields = [
'c.id AS cityId', 'c.uuid AS cityUuid', 'c.name AS cityName',
'p.id AS provinceId', 'p.uuid AS provinceUuid', 'p.name AS provinceName',
];
$ownRows = $this->getEntityManager()->createQueryBuilder()
->select('IDENTITY(da.doctor) AS doctorId', ...$locationFields)
->from(DoctorAddress::class, 'da')
->join('da.city', 'c')
->leftJoin('da.province', 'p')
->where('da.doctor IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getArrayResult();
$map = $this->indexLocationRows($ownRows, []);
$missing = array_values(array_diff($ids, array_keys($map)));
if ($missing) {
$clinicRows = $this->getEntityManager()->createQueryBuilder()
->select('cd.id AS doctorId', ...$locationFields)
->from(Clinic::class, 'cl')
->join('cl.doctors', 'cd')
->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
->join('ca.city', 'c')
->leftJoin('ca.province', 'p')
->where('cd.id IN (:ids)')
->setParameter('ids', $missing)
->getQuery()
->getArrayResult();
$map = $this->indexLocationRows($clinicRows, $map);
}
return $map;
}
/** اولین مکانِ هر پزشک برنده است — پزشک چند-مطبی یک شهر اصلی می‌گیرد. */
private function indexLocationRows(array $rows, array $map): array
{
foreach ($rows as $row) {
$doctorId = (int) $row['doctorId'];
if (isset($map[$doctorId])) {
continue;
}
$map[$doctorId] = [
'city' => [
'uuid' => $row['cityUuid'],
'id' => (string) $row['cityId'],
'name' => $row['cityName'],
'parent' => $row['provinceId'] !== null ? (string) $row['provinceId'] : null,
],
'province' => $row['provinceId'] !== null ? [
'uuid' => $row['provinceUuid'],
'id' => (string) $row['provinceId'],
'name' => $row['provinceName'],
] : null,
];
}
return $map;
}
private function doctorIdsViaClinicLocation(string $field, int $locationId): array
{
$column = $field === 'city' ? 'ca.city' : 'ca.province';