feat(doctors): enhance doctor listing with bookable sorting and filtering, add JSON_EXTRACT DQL function
This commit is contained in:
@@ -21,6 +21,7 @@ doctrine:
|
|||||||
dql:
|
dql:
|
||||||
string_functions:
|
string_functions:
|
||||||
JSON_CONTAINS: App\Shared\Doctrine\JsonContains
|
JSON_CONTAINS: App\Shared\Doctrine\JsonContains
|
||||||
|
JSON_EXTRACT: App\Shared\Doctrine\JsonExtract
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|||||||
+15
-1
@@ -180,7 +180,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
|
|||||||
| `hours_of_work` | خلاصه ساعتهای روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
|
| `hours_of_work` | خلاصه ساعتهای روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
|
||||||
| `active` | `online_booking_enabled && has_active_sessions` | `false` — نوبتدهی غیرفعال |
|
| `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`) |
|
| `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`) یا آدرس کلینیک |
|
| `state_id` | integer | ❌ | Filter by province ID — بر اساس آدرس شخصی پزشک (`doctor_addresses.province_id`) یا آدرس کلینیک |
|
||||||
| `domain` | string | ❌ | دامنهی سایتِ درخواستکننده. اگر دامنهی یک **نماینده سراسری** باشد، فقط پزشکانِ همان نماینده برمیگردند و `city_id`/`state_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`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<ini name="display_errors" value="1" />
|
<ini name="display_errors" value="1" />
|
||||||
<ini name="error_reporting" value="-1" />
|
<ini name="error_reporting" value="-1" />
|
||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<!-- Debug container required: countQueries() in ApiTestCase reads doctrine.debug_data_holder -->
|
||||||
|
<server name="APP_DEBUG" value="1" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Doctor\Repository;
|
namespace App\Doctor\Repository;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\WeeklySchedule;
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Clinic\Entity\Clinic;
|
use App\Clinic\Entity\Clinic;
|
||||||
use App\Doctor\Entity\Doctor;
|
use App\Doctor\Entity\Doctor;
|
||||||
@@ -88,13 +89,31 @@ class DoctorRepository extends ServiceEntityRepository
|
|||||||
if (!empty($filters['name'])) {
|
if (!empty($filters['name'])) {
|
||||||
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
|
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
|
||||||
}
|
}
|
||||||
// Public list shows only doctors with appointment enabled unless the
|
// "دارای نوبت" = appointment flag on AND a weekly schedule exists with
|
||||||
// caller explicitly asks otherwise (e.g. admin passing active=0).
|
// online booking not disabled AND at least one active session — same
|
||||||
$active = isset($filters['active']) ? (bool) $filters['active'] : true;
|
// definition as the `active` field in Doctor::toListArray(), so
|
||||||
$qb->andWhere('d.activeDoctorAppointment = :active')
|
// filter/sort match what the doctor card shows.
|
||||||
->setParameter('active', $active);
|
$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();
|
$total = (new Paginator($qb))->count();
|
||||||
$results = $qb->setFirstResult(($page - 1) * $limit)
|
$results = $qb->setFirstResult(($page - 1) * $limit)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Doctrine;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||||
|
use Doctrine\ORM\Query\AST\Node;
|
||||||
|
use Doctrine\ORM\Query\Parser;
|
||||||
|
use Doctrine\ORM\Query\SqlWalker;
|
||||||
|
use Doctrine\ORM\Query\TokenType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON_EXTRACT(json_doc, path) DQL function for MariaDB/MySQL.
|
||||||
|
* Usage: WHERE JSON_EXTRACT(ws.setting, '$.meta.online_booking_enabled') != 'false'
|
||||||
|
*/
|
||||||
|
class JsonExtract extends FunctionNode
|
||||||
|
{
|
||||||
|
private Node $jsonDoc;
|
||||||
|
private Node $path;
|
||||||
|
|
||||||
|
public function parse(Parser $parser): void
|
||||||
|
{
|
||||||
|
$parser->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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Doctor;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\WeeklySchedule;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Specialty\Entity\Specialty;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public doctors list: bookable doctors (appointment flag on + weekly schedule
|
||||||
|
* with online booking enabled) must rank above non-bookable ones, and the
|
||||||
|
* active=1 filter must return only truly bookable doctors — matching the
|
||||||
|
* `active` field of the list payload.
|
||||||
|
*/
|
||||||
|
class DoctorListBookableSortFilterTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private Specialty $specialty;
|
||||||
|
private Doctor $bookable;
|
||||||
|
private Doctor $noSchedule;
|
||||||
|
private Doctor $flagOff;
|
||||||
|
private Doctor $bookingDisabled;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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<int, array<string, mixed>> */
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user