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']); + } +}