From 34d8a400b74956680af9be11cb51f58055cb4b17 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 11:25:39 +0330 Subject: [PATCH] feat(doctors): enhance doctor listing with bookable sorting and filtering, add JSON_EXTRACT DQL function --- config/packages/doctrine.yaml | 1 + docs/api/doctor.md | 16 ++- phpunit.dist.xml | 2 + src/Doctor/Repository/DoctorRepository.php | 31 ++++- src/Shared/Doctrine/JsonExtract.php | 38 ++++++ .../DoctorListBookableSortFilterTest.php | 108 ++++++++++++++++++ 6 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 src/Shared/Doctrine/JsonExtract.php create mode 100644 tests/Doctor/DoctorListBookableSortFilterTest.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c126e408..55a8bebe 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -21,6 +21,7 @@ doctrine: dql: string_functions: JSON_CONTAINS: App\Shared\Doctrine\JsonContains + JSON_EXTRACT: App\Shared\Doctrine\JsonExtract when@test: doctrine: diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 6fb0fd35..1122ff68 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -180,7 +180,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe | `hours_of_work` | خلاصه ساعت‌های روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» | | `active` | `online_booking_enabled && has_active_sessions` | `false` — نوبت‌دهی غیرفعال | -> **نوبت‌دهی آنلاین غیرفعال:** منبعِ فعال/غیرفعال بودن نوبت‌دهی آنلاین، فیلد `meta.online_booking_enabled` در `WeeklySchedule` پزشک است. اگر `false` باشد، صرف‌نظر از سشن‌های برنامه‌ی هفتگی، `free_turn` همیشه `"نوبت‌دهی آنلاین غیرفعال است"` و `active` برابر `false` برمی‌گردد؛ `hours_of_work` در صورت وجود برنامه حفظ می‌شود. چنین پزشکی در لیست عمومی `GET /api/v1/doctors` (پیش‌فرض `active=true`) نمایش داده نمی‌شود، ولی صفحه‌ی تکی `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است. +> **نوبت‌دهی آنلاین غیرفعال:** منبعِ فعال/غیرفعال بودن نوبت‌دهی آنلاین، فیلد `meta.online_booking_enabled` در `WeeklySchedule` پزشک است. اگر `false` باشد، صرف‌نظر از سشن‌های برنامه‌ی هفتگی، `free_turn` همیشه `"نوبت‌دهی آنلاین غیرفعال است"` و `active` برابر `false` برمی‌گردد؛ `hours_of_work` در صورت وجود برنامه حفظ می‌شود. چنین پزشکی در لیست عمومی `GET /api/v1/doctors` نمایش داده می‌شود ولی پایین‌تر از پزشکان دارای نوبت قرار می‌گیرد و با فیلتر `active=1` حذف می‌شود؛ صفحه‌ی تکی `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است. --- @@ -200,6 +200,20 @@ List doctors with pagination and filters. | `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصی‌شان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) | | `state_id` | integer | ❌ | Filter by province ID — بر اساس آدرس شخصی پزشک (`doctor_addresses.province_id`) یا آدرس کلینیک | | `domain` | string | ❌ | دامنه‌ی سایتِ درخواست‌کننده. اگر دامنه‌ی یک **نماینده سراسری** باشد، فقط پزشکانِ همان نماینده برمی‌گردند و `city_id`/`state_id` نادیده گرفته می‌شوند؛ دامنه شهری/ناشناخته اثری ندارد | +| `gender` | string | ❌ | `man` یا `woman` | +| `degree` | string | ❌ | `expert`, `general`, `specialist`, `subspecialistplus` | +| `name` | string | ❌ | جستجوی `LIKE` روی نام پزشک | +| `sort` | string | ❌ | `ASC` یا `DESC` (پیش‌فرض `DESC`) — مرتب‌سازی ثانویه بر اساس `doctorRate` | +| `active` | `0`/`1` | ❌ | `1` → فقط پزشکان **دارای نوبت** (تعریف پایین). `0` → فقط پزشکانی که فلگ `active_doctor_appointment` آن‌ها خاموش است (کاربرد ادمین). بدون این پارامتر → **همه** پزشکان برمی‌گردند | + +### مرتب‌سازی و تعریف «دارای نوبت» + +پزشک **دارای نوبت** یعنی هر سه شرط برقرار باشد (همان تعریفی که فیلد `active` هر آیتم پاسخ را می‌سازد): +1. فلگ `active_doctor_appointment` روشن، +2. `WeeklySchedule` ثبت‌شده با حداقل یک سشن `active: true`، +3. `meta.online_booking_enabled` برابر `false` نباشد. + +لیست همیشه اول پزشکان دارای نوبت را نشان می‌دهد و بعد بقیه را؛ داخل هر گروه بر اساس `doctorRate` و پارامتر `sort` مرتب می‌شود. ### Response `200` ```json diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd8791..04bf090e 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -14,6 +14,8 @@ + + diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index 57dde496..df2423c7 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -2,6 +2,7 @@ namespace App\Doctor\Repository; +use App\Appointment\Entity\WeeklySchedule; use App\Auth\Entity\User; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; @@ -88,13 +89,31 @@ class DoctorRepository extends ServiceEntityRepository if (!empty($filters['name'])) { $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%'); } - // Public list shows only doctors with appointment enabled unless the - // caller explicitly asks otherwise (e.g. admin passing active=0). - $active = isset($filters['active']) ? (bool) $filters['active'] : true; - $qb->andWhere('d.activeDoctorAppointment = :active') - ->setParameter('active', $active); + // "دارای نوبت" = appointment flag on AND a weekly schedule exists with + // online booking not disabled AND at least one active session — same + // definition as the `active` field in Doctor::toListArray(), so + // filter/sort match what the doctor card shows. + $bookable = fn(string $alias): string => sprintf( + 'd.activeDoctorAppointment = true AND EXISTS(SELECT %1$s.id FROM %2$s %1$s WHERE %1$s.doctor = d' + . ' AND (JSON_EXTRACT(%1$s.setting, \'$.meta.online_booking_enabled\') IS NULL OR JSON_EXTRACT(%1$s.setting, \'$.meta.online_booking_enabled\') != \'false\')' + . ' AND JSON_CONTAINS(JSON_EXTRACT(%1$s.setting, \'$**.sessions[*].active\'), \'true\') = 1)', + $alias, + WeeklySchedule::class + ); - $qb->orderBy('d.doctorRate', $sort); + if (isset($filters['active'])) { + if ((bool) $filters['active']) { + $qb->andWhere($bookable('wsf')); + } else { + // Legacy admin escape hatch: active=0 → flag explicitly off. + $qb->andWhere('d.activeDoctorAppointment = false'); + } + } + + // Bookable doctors always rank above non-bookable ones. + $qb->addSelect('(CASE WHEN ' . $bookable('wss') . ' THEN 1 ELSE 0 END) AS HIDDEN bookableRank') + ->orderBy('bookableRank', 'DESC') + ->addOrderBy('d.doctorRate', $sort); $total = (new Paginator($qb))->count(); $results = $qb->setFirstResult(($page - 1) * $limit) diff --git a/src/Shared/Doctrine/JsonExtract.php b/src/Shared/Doctrine/JsonExtract.php new file mode 100644 index 00000000..6ab28156 --- /dev/null +++ b/src/Shared/Doctrine/JsonExtract.php @@ -0,0 +1,38 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $this->jsonDoc = $parser->StringPrimary(); + $parser->match(TokenType::T_COMMA); + $this->path = $parser->StringPrimary(); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + return sprintf( + 'JSON_EXTRACT(%s, %s)', + $this->jsonDoc->dispatch($sqlWalker), + $this->path->dispatch($sqlWalker) + ); + } +} diff --git a/tests/Doctor/DoctorListBookableSortFilterTest.php b/tests/Doctor/DoctorListBookableSortFilterTest.php new file mode 100644 index 00000000..6b6b7346 --- /dev/null +++ b/tests/Doctor/DoctorListBookableSortFilterTest.php @@ -0,0 +1,108 @@ +client->catchExceptions(false); + + // db_test is shared across runs — scope every request to a fresh + // specialty so only this test's doctors are visible. + $this->specialty = new Specialty('تخصص تست نوبت', 'sp-' . bin2hex(random_bytes(6))); + $this->em->persist($this->specialty); + + $this->bookable = $this->makeDoctor('دکتر نوبت‌دار'); + $this->noSchedule = $this->makeDoctor('دکتر بدون برنامه'); + $this->flagOff = $this->makeDoctor('دکتر غیرفعال'); + $this->bookingDisabled = $this->makeDoctor('دکتر رزرو خاموش'); + + $this->flagOff->setActiveDoctorAppointment(false); + + $days = [['sessions' => [['active' => true, 'start_time' => '09:00', 'end_time' => '13:00']]]]; + $this->em->persist(new WeeklySchedule($this->bookable, $days)); + $this->em->persist(new WeeklySchedule($this->flagOff, $days)); + + $disabled = new WeeklySchedule($this->bookingDisabled, $days); + $disabled->setMeta(['online_booking_enabled' => false]); + $this->em->persist($disabled); + + $this->em->flush(); + } + + private function makeDoctor(string $name): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), $name); + $doctor->getSpecialties()->add($this->specialty); + $this->em->persist($doctor); + + return $doctor; + } + + /** @return array> */ + private function listDoctors(string $extraQuery = ''): array + { + $this->client->request( + 'GET', + '/api/v1/doctors?specialty_id=' . $this->specialty->getId() . '&limit=50' . $extraQuery + ); + $this->assertSame(200, $this->responseCode(), substr($this->client->getResponse()->getContent(), 0, 1500)); + $body = json_decode($this->client->getResponse()->getContent(), true); + + return $body['data']; + } + + public function testDefaultListShowsAllDoctorsBookableFirst(): void + { + $items = $this->listDoctors(); + + $this->assertCount(4, $items); + $this->assertSame($this->bookable->getUuid(), $items[0]['uuid']); + $this->assertTrue($items[0]['active']); + + $rest = array_column(array_slice($items, 1), 'uuid'); + sort($rest); + $expected = [$this->noSchedule->getUuid(), $this->flagOff->getUuid(), $this->bookingDisabled->getUuid()]; + sort($expected); + $this->assertSame($expected, $rest); + foreach (array_slice($items, 1) as $item) { + $this->assertFalse($item['active'], $item['name'] . ' must not be bookable'); + } + } + + public function testActiveFilterReturnsOnlyBookableDoctors(): void + { + $items = $this->listDoctors('&active=1'); + + $this->assertCount(1, $items); + $this->assertSame($this->bookable->getUuid(), $items[0]['uuid']); + $this->assertTrue($items[0]['active']); + } + + public function testActiveZeroReturnsOnlyFlagOffDoctors(): void + { + $items = $this->listDoctors('&active=0'); + + $this->assertCount(1, $items); + $this->assertSame($this->flagOff->getUuid(), $items[0]['uuid']); + } +}