From 3363dfbf22b2be688e6ff790b972a4d4884c0f7b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 08:08:48 +0330 Subject: [PATCH] feat(doctor): expose city/state in public doctor list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/README.md | 4 +- docs/api/clinic.md | 9 +- docs/api/doctor.md | 25 ++- src/Clinic/Controller/ClinicController.php | 7 +- src/Doctor/Controller/DoctorController.php | 10 +- src/Doctor/Entity/Doctor.php | 11 +- src/Doctor/Repository/DoctorRepository.php | 82 +++++++++ src/Shared/Controller/BaseController.php | 4 + tests/Doctor/DoctorListLocationTest.php | 190 +++++++++++++++++++++ 9 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 tests/Doctor/DoctorListLocationTest.php diff --git a/docs/api/README.md b/docs/api/README.md index a4b6cb73..0b821c01 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -31,12 +31,14 @@ Authorization: Bearer { "success": true, "data": { ... } } // Paginated -{ "success": true, "data": [...], "meta": { "totalRecords": 100, "totalPages": 5, "currentPage": 1 } } +{ "success": true, "data": [...], "meta": { "totalRecords": 100, "totalPages": 5, "currentPage": 1, "limit": 20 } } // Error { "success": false, "data": null, "errors": [{ "code": "ERR_XXX_000", "message": "..." }] } ``` +> `meta.limit` اندازهٔ صفحهٔ **واقعاً اعمال‌شده** است. ریپازیتوری‌ها `limit` درخواستی را به سقف خودشان کاهش می‌دهند (مثلاً لیست پزشکان: سقف ۵۰)، پس برای پیمایش کامل به `meta.totalPages` تکیه کن — نه به این فرض که «تعداد آیتم کمتر از limit درخواستی یعنی صفحهٔ آخر». + --- ## Persian digit normalization (global) diff --git a/docs/api/clinic.md b/docs/api/clinic.md index a6c85fde..cd1767c9 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -289,7 +289,13 @@ Get doctors associated with a clinic. "point": "4.8", "free_turn": "پنجشنبه 09:00–13:00", "hours_of_work": "شنبه تا چهارشنبه | پنجشنبه", - "active": true + "active": true, + "city": [ + { "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" } + ], + "state": [ + { "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" } + ] } ], "meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 } @@ -302,6 +308,7 @@ Get doctors associated with a clinic. | `free_turn` | string | Next available appointment (e.g. `پنجشنبه 09:00–13:00`), or `نوبت آزادی موجود نیست` if the doctor has no active weekly schedule | | `hours_of_work` | string | Working-days summary, or `برنامه کاری تنظیم نشده` when unscheduled | | `active` | boolean | `true` only when appointments are enabled **and** the doctor has an active schedule | +| `city` / `state` | array | مکان خودِ پزشک (آدرس شخصی، و در نبودش آدرس کلینیک). آرایه با حداکثر یک عضو؛ پزشک بدون آدرس `[]`. جزئیات و قاعدهٔ انتخاب در [doctor.md](doctor.md#city--state-در-پاسخ-لیست) | > `free_turn`/`hours_of_work`/`active` are computed from each doctor's `WeeklySchedule` (loaded in bulk by the endpoint). Without a schedule they fall back to the "not set" values. diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 6d5041f4..6e0a2663 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -204,7 +204,7 @@ List doctors with pagination and filters. | Param | Type | Required | Description | |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | -| `limit` | integer | ❌ | Default: 20 | +| `limit` | integer | ❌ | Default: 10. **حداکثر ۵۰** — مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند. مقدار واقعاً اعمال‌شده در `meta.limit` برمی‌گردد؛ برای پیمایش کامل به `meta.totalPages` تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی» | | `search` | string | ❌ | Search in title | | `specialty_id` | integer | ❌ | Filter by specialty ID | | `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصی‌شان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) | @@ -228,19 +228,38 @@ List doctors with pagination and filters. "free_turn": "دوشنبه 09:00–13:00", "hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00", "active": true, - "owner_status": "claimed" + "owner_status": "claimed", + "city": [ + { "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" } + ], + "state": [ + { "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" } + ] } ], "meta": { "totalRecords": 50, "totalPages": 3, - "currentPage": 1 + "currentPage": 1, + "limit": 50 } } ``` > ℹ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند. +### `city` / `state` در پاسخ لیست + +آرایه با حداکثر یک عضو — هم‌شکل با `city`/`state` در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها. + +- منبع مکان **دقیقاً همان قاعده‌ای است که فیلتر `city_id`/`state_id` اعمال می‌کند**: اول آدرس شخصی پزشک (`doctor_addresses` با `doctor_id` مقداردار)، و اگر نداشت آدرس کلینیکی که عضو آن است (`doctor_addresses` با `clinic_id` مقداردار و `doctor_id` تهی). یعنی هر پزشکی که با `city_id=X` برگردد، در پاسخ هم همان شهر را اعلام می‌کند. +- پزشک چند-مطبی **یک شهر اصلی** می‌گیرد (اولین مکان یافت‌شده) — نه فهرست همهٔ شهرها. +- پزشک بدون هیچ آدرس: `"city": []` و `"state": []` (آرایهٔ خالی، نه `null`). +- `city[].parent` شناسهٔ استان است. +- استخراج مکان دسته‌ای انجام می‌شود (`DoctorRepository::findLocationsByDoctors`) — حداکثر دو کوئری ثابت، مستقل از تعداد پزشکان در صفحه. + +> 🔗 مصرف‌کننده: `nobat724_front/app/sitemap.js` با این فیلد تشخیص می‌دهد هر پزشک به کدام دامنهٔ شهری تعلق دارد (canonical). تغییر شکل این فیلد قرارداد آن را می‌شکند. + --- ## PATCH `/api/v1/doctor/{uuid}` diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 74dc7dba..3b9b9fd0 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -327,8 +327,13 @@ class ClinicController extends BaseController $scheduleMap[$schedule->getDoctor()->getId()][] = $schedule; } + $locationMap = $this->doctorRepo->findLocationsByDoctors($clinicDoctors); + $doctors = array_map( - fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []), + fn(Doctor $d) => $d->toListArray( + $scheduleMap[$d->getId()] ?? [], + $locationMap[$d->getId()] ?? null + ), $clinicDoctors ); diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 2cb0aa7b..3a67099b 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -260,8 +260,16 @@ class DoctorController extends BaseController $scheduleMap[$schedule->getDoctor()->getId()][] = $schedule; } + $locationMap = $this->doctorRepo->findLocationsByDoctors($result['items']); + return $this->paginated( - array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []), $result['items']), + array_map( + fn(Doctor $d) => $d->toListArray( + $scheduleMap[$d->getId()] ?? [], + $locationMap[$d->getId()] ?? null + ), + $result['items'] + ), $result['total'], $result['page'], $result['limit'] diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index 5118268d..f30c5443 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -537,7 +537,12 @@ class Doctor } /** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */ - public function toListArray(array $schedules = []): array + /** + * @param array{city: ?array, province: ?array}|null $location + * شهر/استان از DoctorRepository::findLocationsByDoctors — این Entity به آدرس + * کلینیک دسترسی ندارد، پس مکان دسته‌ای بیرون حل و تزریق می‌شود. + */ + public function toListArray(array $schedules = [], ?array $location = null): array { $sf = $this->computeScheduleFields($schedules); return [ @@ -558,6 +563,10 @@ class Doctor 'hours_of_work' => $sf['hours_of_work'], 'active' => $this->activeDoctorAppointment && $sf['has_schedule'], 'owner_status' => $this->ownerStatus, + // آرایه — هم‌شکل با city/state در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها. + // پزشک بدون مکان آرایهٔ خالی می‌گیرد (نه null) تا مصرف‌کننده شرط یکسانی بنویسد. + 'city' => isset($location['city']) ? [$location['city']] : [], + 'state' => isset($location['province']) ? [$location['province']] : [], ]; } diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index 57dde496..e9fac883 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -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 + */ + 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'; diff --git a/src/Shared/Controller/BaseController.php b/src/Shared/Controller/BaseController.php index 8e094afa..238599f7 100644 --- a/src/Shared/Controller/BaseController.php +++ b/src/Shared/Controller/BaseController.php @@ -22,6 +22,10 @@ abstract class BaseController extends AbstractController 'totalRecords' => $total, 'totalPages' => (int) ceil($total / max($limit, 1)), 'currentPage' => $page, + // اندازهٔ صفحهٔ واقعاً اعمال‌شده. ریپازیتوری‌ها limit را به سقف خود کاهش + // می‌دهند؛ بدون این فیلد کلاینت نمی‌فهمد درخواستش کوتاه شده و ممکن است + // صفحه‌بندی را زودهنگام تمام‌شده بپندارد. + 'limit' => $limit, ]); } diff --git a/tests/Doctor/DoctorListLocationTest.php b/tests/Doctor/DoctorListLocationTest.php new file mode 100644 index 00000000..2e0302c0 --- /dev/null +++ b/tests/Doctor/DoctorListLocationTest.php @@ -0,0 +1,190 @@ +em->persist($province); + $city = new City($cityName, $province); + $this->em->persist($city); + + return $city; + } + + private function makeDoctor(string $name): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), $name); + $this->em->persist($doctor); + + return $doctor; + } + + private function giveOwnAddress(Doctor $doctor, City $city): void + { + $address = DoctorAddress::forDoctor($doctor); + $address->setCity($city)->setProvince($city->getProvince()); + $this->em->persist($address); + } + + /** @return array map of doctor name => list payload */ + private function fetchListByName(string $name): array + { + $this->client->request('GET', '/api/v1/doctors?limit=50&name=' . urlencode($name)); + $this->assertSame(200, $this->responseCode()); + $payload = json_decode($this->client->getResponse()->getContent(), true); + + $byName = []; + foreach ($payload['data'] as $row) { + $byName[$row['name']] = $row; + } + + return $byName; + } + + public function testDoctorWithOwnAddressReportsItsCity(): void + { + $city = $this->makeCity('یاسوج', 'کهگیلویه و بویراحمد'); + $marker = 'loc-own-' . bin2hex(random_bytes(4)); + $doctor = $this->makeDoctor($marker); + $this->giveOwnAddress($doctor, $city); + $this->em->flush(); + + $row = $this->fetchListByName($marker)[$marker] ?? null; + $this->assertNotNull($row, 'doctor missing from list'); + + $this->assertCount(1, $row['city']); + $this->assertSame('یاسوج', $row['city'][0]['name']); + $this->assertSame((string) $city->getId(), $row['city'][0]['id']); + $this->assertSame((string) $city->getProvince()->getId(), $row['city'][0]['parent']); + + $this->assertCount(1, $row['state']); + $this->assertSame('کهگیلویه و بویراحمد', $row['state'][0]['name']); + } + + public function testDoctorWithoutOwnAddressFallsBackToClinicCity(): void + { + $city = $this->makeCity('تبریز', 'آذربایجان شرقی'); + $marker = 'loc-clinic-' . bin2hex(random_bytes(4)); + $doctor = $this->makeDoctor($marker); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $this->em->persist($clinic); + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + // آدرس کلینیک ردیفی از DoctorAddress با doctor NULL و clinicId پرشده است. + $clinicAddress = DoctorAddress::forClinic($clinic->getId()); + $clinicAddress->setCity($city)->setProvince($city->getProvince()); + $this->em->persist($clinicAddress); + $this->em->flush(); + + $row = $this->fetchListByName($marker)[$marker] ?? null; + $this->assertNotNull($row, 'doctor missing from list'); + + $this->assertCount(1, $row['city'], 'clinic-located doctor must still report a city'); + $this->assertSame('تبریز', $row['city'][0]['name']); + } + + public function testDoctorWithNoLocationReportsEmptyArrays(): void + { + $marker = 'loc-none-' . bin2hex(random_bytes(4)); + $this->makeDoctor($marker); + $this->em->flush(); + + $row = $this->fetchListByName($marker)[$marker] ?? null; + $this->assertNotNull($row, 'doctor missing from list'); + + $this->assertSame([], $row['city']); + $this->assertSame([], $row['state']); + } + + public function testReportedCityMatchesCityIdFilter(): void + { + $city = $this->makeCity('یزد', 'یزد'); + $marker = 'loc-filter-' . bin2hex(random_bytes(4)); + $doctor = $this->makeDoctor($marker); + $this->giveOwnAddress($doctor, $city); + $this->em->flush(); + + $this->client->request('GET', '/api/v1/doctors?limit=50&city_id=' . $city->getId()); + $this->assertSame(200, $this->responseCode()); + $payload = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertNotEmpty($payload['data']); + foreach ($payload['data'] as $row) { + $this->assertNotEmpty( + $row['city'], + "doctor {$row['name']} matched city_id filter but reports no city" + ); + $this->assertSame((string) $city->getId(), $row['city'][0]['id']); + } + } + + /** + * سهمِ خودِ حل‌مکان اندازه‌گیری می‌شود، نه کل اندپوینت: پاسخ لیست lazy-loadهای + * قدیمی (specialties) هم دارد که با تعداد پزشک رشد می‌کنند و ربطی به این تغییر + * ندارند. پس مستقیم ریپازیتوری تست می‌شود — باید حداکثر ۲ کوئری باشد، ثابت. + */ + public function testLocationResolutionCostIsConstant(): void + { + $city = $this->makeCity('اصفهان', 'اصفهان'); + $repo = static::getContainer()->get(DoctorRepository::class); + + $makeDoctors = function (int $count) use ($city): array { + $doctors = []; + for ($i = 0; $i < $count; $i++) { + $doctor = $this->makeDoctor('loc-cost-' . bin2hex(random_bytes(4))); + $this->giveOwnAddress($doctor, $city); + $doctors[] = $doctor; + } + $this->em->flush(); + + return $doctors; + }; + + $few = $makeDoctors(2); + $many = $makeDoctors(12); + + $qFew = $this->countQueries(fn () => $repo->findLocationsByDoctors($few)); + $qMany = $this->countQueries(fn () => $repo->findLocationsByDoctors($many)); + + $this->assertSame($qFew, $qMany, "location resolution scales with doctor count: $qFew -> $qMany"); + $this->assertLessThanOrEqual(2, $qMany, 'location resolution must cost at most 2 queries'); + + // و همچنان درست کار کند + $resolved = $repo->findLocationsByDoctors($many); + $this->assertCount(12, $resolved); + foreach ($many as $doctor) { + $this->assertSame('اصفهان', $resolved[$doctor->getId()]['city']['name']); + } + } + + public function testPaginationMetaExposesAppliedLimit(): void + { + $this->client->request('GET', '/api/v1/doctors?limit=500'); + $this->assertSame(200, $this->responseCode()); + $payload = json_decode($this->client->getResponse()->getContent(), true); + + // سقف ریپازیتوری ۵۰ است؛ meta باید مقدار واقعاً اعمال‌شده را بگوید نه ۵۰۰. + $this->assertSame(50, $payload['meta']['limit']); + } +}