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