feat(doctor): update schedule fields in doctor API responses to reflect actual availability

This commit is contained in:
hamed
2026-06-15 15:11:29 +03:30
parent ec36eefeb8
commit c662eb9b4f
5 changed files with 398 additions and 28 deletions
@@ -0,0 +1,250 @@
# رفع فیلدهای free_turn و hours_of_work در API لیست پزشکان
## زمینه
endpoint `GET /api/v1/doctors` و `GET /api/v1/doctor/{uuid}` در پاسخ خود دو فیلد `free_turn` و `hours_of_work` دارند که همیشه مقدار ثابت «نوبت آزادی موجود نیست» و «برنامه کاری تنظیم نشده» برمی‌گردانند — حتی برای پزشکانی که برنامه هفتگی (`weekly_schedules`) ست کرده‌اند. همچنین `activeDoctorAppointment` (فیلد `active`) باید با وجود یا نبود برنامه هفتگی هماهنگ باشد.
## مشکل / هدف
- `free_turn`: باید نزدیک‌ترین روز کاری پزشک را نشان دهد (مثلاً «شنبه ۹:۰۰–۱۳:۰۰»)؛ اگر برنامه‌ای نداشت «نوبت آزادی موجود نیست»
- `hours_of_work`: باید ساعت‌های کاری روزهای فعال را خلاصه کند (مثلاً «شنبه تا چهارشنبه ۹–۱۳ و ۱۴–۱۸»)؛ اگر برنامه نداشت «برنامه کاری تنظیم نشده»
- `active` (`activeDoctorAppointment`): اگر پزشک برنامه هفتگی نداشته باشد یا همه روزها `sessions: []` باشند، باید `false` برگردد — نوبت‌دهی غیرفعال
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Doctor/Entity/Doctor.php` | متدهای `toListArray()` و `toDetailArray()` — مقادیر hardcoded اینجاست |
| `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — JOIN با weekly_schedules ندارد |
| `src/Doctor/Controller/DoctorController.php` | `list()` (خط ۲۳۳) و `show()` — از toListArray/toDetailArray استفاده می‌کنند |
| `src/Appointment/Entity/WeeklySchedule.php` | Entity برنامه هفتگی — فیلد `setting` آرایه ۷ عنصری (شنبه تا جمعه) |
| `src/Appointment/Repository/WeeklyScheduleRepository.php` | `findByDoctor(Doctor $doctor): ?WeeklySchedule` موجود است |
## وضعیت فعلی
### Doctor.php — toListArray() خط ۱۷۸:
```php
public function toListArray(): array
{
return [
// ...
'free_turn' => 'نوبت آزادی موجود نیست', // ❌ hardcoded
'hours_of_work' => 'برنامه کاری تنظیم نشده', // ❌ hardcoded
'active' => $this->activeDoctorAppointment,
];
}
```
### Doctor.php — toDetailArray() خط ۱۹۸:
```php
'free_turn' => 'نوبت آزادی موجود نیست', // ❌ hardcoded
'hours_of_work' => 'برنامه کاری تنظیم نشده', // ❌ hardcoded
```
### ساختار setting در weekly_schedules:
آرایه ۷ عنصری (ایندکس ۰=شنبه، ۱=یکشنبه، ..., ۶=جمعه):
```json
[
{"sessions": [{"active": true, "start_time": "09:00", "end_time": "13:00", ...}, ...]},
{"sessions": [{"active": true, "start_time": "14:00", "end_time": "18:00", ...}]},
...
{"sessions": []} // جمعه — تعطیل
]
```
### DoctorRepository.findWithFilters() — JOIN ندارد:
```php
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
// ❌ هیچ JOIN با weekly_schedules ندارد
```
## وظایف
### ۱. اضافه کردن متد کمکی به Doctor entity
در `src/Doctor/Entity/Doctor.php` یک متد `computeScheduleFields(?WeeklySchedule $schedule): array` اضافه کن:
```php
use App\Appointment\Entity\WeeklySchedule;
private const DAY_NAMES = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
public function computeScheduleFields(?WeeklySchedule $schedule): array
{
if ($schedule === null) {
return [
'free_turn' => 'نوبت آزادی موجود نیست',
'hours_of_work' => 'برنامه کاری تنظیم نشده',
'has_schedule' => false,
];
}
$setting = $schedule->getSetting(); // آرایه ۷ عنصری
// محاسبه hours_of_work — ساعت‌های روزهای فعال
$workDays = [];
foreach ($setting as $dayIdx => $day) {
$activeSessions = array_filter(
$day['sessions'] ?? [],
fn($s) => ($s['active'] ?? false) && !empty($s['start_time'])
);
if (!empty($activeSessions)) {
$times = array_map(fn($s) => $s['start_time'] . '' . $s['end_time'], $activeSessions);
$workDays[] = self::DAY_NAMES[$dayIdx] . ': ' . implode(' و ', $times);
}
}
if (empty($workDays)) {
return [
'free_turn' => 'نوبت آزادی موجود نیست',
'hours_of_work' => 'برنامه کاری تنظیم نشده',
'has_schedule' => false,
];
}
// محاسبه free_turn — نزدیک‌ترین روز کاری فعال
// روز هفته فعلی را بگیر (PHP: 0=یکشنبه...6=شنبه → تبدیل به ایندکس ایرانی)
$phpDay = (int) date('w'); // 0=Sun, 6=Sat
$iranDay = $phpDay === 0 ? 1 : ($phpDay === 6 ? 0 : $phpDay + 1); // 0=Sat, 1=Sun, ...
$freeTurn = null;
for ($i = 0; $i < 7; $i++) {
$idx = ($iranDay + $i) % 7;
$activeSessions = array_filter(
$setting[$idx]['sessions'] ?? [],
fn($s) => ($s['active'] ?? false) && !empty($s['start_time'])
);
if (!empty($activeSessions)) {
$first = array_values($activeSessions)[0];
$freeTurn = self::DAY_NAMES[$idx] . ' ' . $first['start_time'] . '' . $first['end_time'];
break;
}
}
return [
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
'hours_of_work' => implode(' | ', $workDays),
'has_schedule' => true,
];
}
```
### ۲. آپدیت toListArray() و toDetailArray()
`toListArray()` و `toDetailArray()` باید یک `?WeeklySchedule` دریافت کنند:
```php
public function toListArray(?WeeklySchedule $schedule = null): array
{
$scheduleFields = $this->computeScheduleFields($schedule);
return [
// ... بقیه فیلدها
'free_turn' => $scheduleFields['free_turn'],
'hours_of_work' => $scheduleFields['hours_of_work'],
'active' => $this->activeDoctorAppointment && $scheduleFields['has_schedule'],
];
}
public function toDetailArray(?WeeklySchedule $schedule = null): array
{
$scheduleFields = $this->computeScheduleFields($schedule);
return [
// ... بقیه فیلدها
'free_turn' => $scheduleFields['free_turn'],
'hours_of_work' => $scheduleFields['hours_of_work'],
'active' => $this->activeDoctorAppointment && $scheduleFields['has_schedule'],
// ... address, state, city
];
}
```
### ۳. آپدیت DoctorRepository.findWithFilters()
LEFT JOIN با `weekly_schedules` اضافه کن تا schedule را یکجا load کند:
```php
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->addSelect('d') // ensure d is selected for eager loading
->distinct();
```
**نکته مهم:** از eager loading یا جداگانه query استفاده کن. چون Doctor entity OneToOne با WeeklySchedule ندارد (رابطه از طرف WeeklySchedule است)، بهترین راه این است که در Controller بعد از گرفتن لیست doctors، scheduleها را batch load کنی.
### ۴. آپدیت DoctorController — متد list()
در `src/Doctor/Controller/DoctorController.php` متد `list()` خط ۲۳۳:
```php
public function __construct(
// اضافه کن:
private readonly WeeklyScheduleRepository $scheduleRepo,
// ...
)
public function list(Request $request): JsonResponse
{
$filters = $request->query->all();
$result = $this->doctorRepo->findWithFilters($filters);
// batch load schedules برای همه doctors
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
}
return $this->paginated(
array_map(
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null),
$result['items']
),
$result['total'],
$result['page'],
$result['limit']
);
}
```
### ۵. اضافه کردن findByDoctors() به WeeklyScheduleRepository
```php
/**
* @param Doctor[] $doctors
* @return WeeklySchedule[]
*/
public function findByDoctors(array $doctors): array
{
if (empty($doctors)) return [];
return $this->createQueryBuilder('ws')
->where('ws.doctor IN (:doctors)')
->setParameter('doctors', $doctors)
->getQuery()
->getResult();
}
```
### ۶. آپدیت متدهای show() در DoctorController
هر جایی که `$doctor->toDetailArray()` صدا زده می‌شود (خطوط ۱۱۷، ۱۶۳، ۱۸۴، ۳۱۲)، schedule را پاس بده:
```php
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => $doctor->toDetailArray($schedule)]);
```
## نکات مهم
- **Doctor entity رابطه مستقیم با WeeklySchedule ندارد** — رابطه OneToOne از طرف WeeklySchedule به Doctor است؛ پس `$doctor->getSchedule()` وجود ندارد و باید از Repository بخوانی
- **Circular dependency**: `Doctor.php` نباید مستقیم WeeklyScheduleRepository inject کند — بهتر است در Controller schedule را بگیری و به متد پاس بدهی (دقیقاً همان الگوی پیشنهادی بالا)
- **active flag**: فقط وقتی هم `activeDoctorAppointment=true` هم `has_schedule=true` باشد نوبت‌دهی فعال است
- **batch load**: برای endpoint لیست، حتماً از `findByDoctors()` batch استفاده کن — N+1 query نساز
- **ایندکس روزها**: `setting[0]=شنبه`, `setting[1]=یکشنبه`, ..., `setting[6]=جمعه` — PHP `date('w')` را باید به این ایندکس تبدیل کنی
- **sessions خالی**: اگر `sessions: []` باشد آن روز تعطیل است — فقط `active: true` و `start_time` غیرخالی معتبر است
- **مستندات**: بعد از تغییر، `docs/api/doctor.md` را آپدیت کن (فیلدهای `free_turn`، `hours_of_work`، `active` را توضیح بده)
- هیچ migration لازم نیست — Entity تغییر ساختاری ندارد
+36 -15
View File
@@ -80,18 +80,23 @@ Get doctor detail with clinics.
"data": {
"data": {
"uuid": "550e8400-...",
"title": "دکتر علی احمدی",
"gender": "male",
"name": "دکتر علی احمدی",
"gender": "man",
"medical_system_code": "12345",
"degree": "متخصص",
"info": "...",
"image": "https://...",
"doctor_rate": 4.5,
"active_doctor_appointment": true,
"specialties": [{ "id": 1, "name": "قلب و عروق" }],
"doctor_services": [{ "id": 3, "name": "نوار قلب" }],
"clinics": [{ "uuid": "...", "name": "کلینیک الوند" }],
"created_at": 1717000000
"degree": "specialist",
"detail": "...",
"img": [],
"satisfaction": "60",
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true,
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
"address": [],
"state": [],
"city": [],
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }]
}
}
}
@@ -144,6 +149,16 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
---
### Schedule Fields Notes
| Field | When schedule exists | When no schedule |
|-------|---------------------|-----------------|
| `free_turn` | نزدیک‌ترین روز/ساعت کاری از امروز (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
| `hours_of_work` | خلاصه ساعت‌های روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
| `active` | `activeDoctorAppointment && has_active_sessions` | `false` — نوبت‌دهی غیرفعال |
---
## GET `/api/v1/doctors`
List doctors with pagination and filters.
@@ -167,10 +182,16 @@ List doctors with pagination and filters.
"data": [
{
"uuid": "...",
"title": "دکتر علی احمدی",
"degree": "متخصص",
"doctor_rate": 4.5,
"image": "https://..."
"name": "دکتر علی احمدی",
"gender": "man",
"degree": "specialist",
"img": [],
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق" }],
"satisfaction": "60",
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true
}
],
"meta": {
@@ -19,6 +19,19 @@ class WeeklyScheduleRepository extends ServiceEntityRepository
return $this->findOneBy(['doctor' => $doctor]);
}
/** @param Doctor[] $doctors @return WeeklySchedule[] */
public function findByDoctors(array $doctors): array
{
if (empty($doctors)) {
return [];
}
return $this->createQueryBuilder('ws')
->where('ws.doctor IN (:doctors)')
->setParameter('doctors', $doctors)
->getQuery()
->getResult();
}
public function findByUuid(string $uuid): ?WeeklySchedule
{
return $this->findOneBy(['uuid' => $uuid]);
+16 -5
View File
@@ -2,6 +2,7 @@
namespace App\Doctor\Controller;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Clinic\Entity\Clinic;
@@ -37,6 +38,7 @@ class DoctorController extends BaseController
private readonly CityRepository $cityRepo,
private readonly UserRepository $userRepo,
private readonly FileValidatorService $fileValidator,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly string $projectDir,
) {}
@@ -114,7 +116,8 @@ class DoctorController extends BaseController
$this->userRepo->save($user);
}
return $this->success(['data' => $doctor->toDetailArray()], 201);
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => $doctor->toDetailArray($schedule)], 201);
}
#[OA\Get(
@@ -160,7 +163,8 @@ class DoctorController extends BaseController
],
], $clinics);
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]);
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => $clinicData])]);
}
#[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])]
@@ -181,7 +185,8 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
}
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => [[
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => [[
'id' => (string) $clinic->getId(),
'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(),
@@ -235,8 +240,13 @@ class DoctorController extends BaseController
$filters = $request->query->all();
$result = $this->doctorRepo->findWithFilters($filters);
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
}
return $this->paginated(
array_map(fn(Doctor $d) => $d->toListArray(), $result['items']),
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null), $result['items']),
$result['total'],
$result['page'],
$result['limit']
@@ -309,7 +319,8 @@ class DoctorController extends BaseController
$this->hydrateDoctor($doctor, $data);
$this->doctorRepo->save($doctor);
return $this->success(['data' => $doctor->toDetailArray()]);
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => $doctor->toDetailArray($schedule)]);
}
#[OA\Delete(
+83 -8
View File
@@ -2,6 +2,7 @@
namespace App\Doctor\Entity;
use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User;
use App\DoctorService\Entity\DoctorService;
use App\Location\Entity\City;
@@ -167,6 +168,78 @@ class Doctor
private function touch(): void { $this->updatedAt = time(); }
private const DAY_NAMES = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
private function computeScheduleFields(?WeeklySchedule $schedule): array
{
if ($schedule === null) {
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
}
$setting = $schedule->getSetting();
// استخراج ساعت‌های هر روز — key: dayIdx، value: رشته ساعت‌ها یا null
$dayTimes = [];
for ($i = 0; $i < 7; $i++) {
$activeSessions = array_values(array_filter(
$setting[$i]['sessions'] ?? [],
fn($s) => ($s['active'] ?? false) && !empty($s['start_time'])
));
$dayTimes[$i] = empty($activeSessions)
? null
: implode(' و ', array_map(fn($s) => $s['start_time'] . '' . $s['end_time'], $activeSessions));
}
$hasAnyDay = array_filter($dayTimes) !== [];
if (!$hasAnyDay) {
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
}
// گروه‌بندی روزهای متوالی با ساعت یکسان
// مثال: شنبه–پنجشنبه ۹–۱۳ و ۱۴–۱۸ | جمعه تعطیل
$groups = [];
$current = ['start' => 0, 'times' => $dayTimes[0]];
for ($i = 1; $i < 7; $i++) {
if ($dayTimes[$i] === $current['times']) {
continue; // ادامه همان گروه
}
if ($current['times'] !== null) {
$groups[] = ['start' => $current['start'], 'end' => $i - 1, 'times' => $current['times']];
}
$current = ['start' => $i, 'times' => $dayTimes[$i]];
}
if ($current['times'] !== null) {
$groups[] = ['start' => $current['start'], 'end' => 6, 'times' => $current['times']];
}
$parts = [];
foreach ($groups as $g) {
$parts[] = $g['start'] === $g['end']
? self::DAY_NAMES[$g['start']]
: self::DAY_NAMES[$g['start']] . ' تا ' . self::DAY_NAMES[$g['end']];
}
// نزدیک‌ترین روز کاری — PHP date('w'): 0=Sun,6=Sat → ایندکس ایرانی: 0=Sat,...,6=Fri
$phpDay = (int) date('w');
$iranDay = $phpDay === 0 ? 1 : ($phpDay === 6 ? 0 : $phpDay + 1);
$freeTurn = null;
for ($i = 0; $i < 7; $i++) {
$idx = ($iranDay + $i) % 7;
if ($dayTimes[$idx] !== null) {
$firstTime = explode(' و ', $dayTimes[$idx])[0];
$freeTurn = self::DAY_NAMES[$idx] . ' ' . $firstTime;
break;
}
}
return [
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
'hours_of_work' => implode(' | ', $parts),
'has_schedule' => true,
];
}
public function getExperience(): int
{
if ($this->activityTime === null) {
@@ -175,8 +248,9 @@ class Doctor
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
}
public function toListArray(): array
public function toListArray(?WeeklySchedule $schedule = null): array
{
$sf = $this->computeScheduleFields($schedule);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -189,14 +263,15 @@ class Doctor
], $this->specialties->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'free_turn' => 'نوبت آزادی موجود نیست',
'hours_of_work' => 'برنامه کاری تنظیم نشده',
'active' => $this->activeDoctorAppointment,
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
];
}
public function toDetailArray(): array
public function toDetailArray(?WeeklySchedule $schedule = null): array
{
$sf = $this->computeScheduleFields($schedule);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -211,15 +286,15 @@ class Doctor
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'active' => $this->activeDoctorAppointment,
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'img' => $this->images ?? [],
'expertise' => array_map(fn(DoctorService $ds) => [
'uuid' => $ds->getUuid(), 'id' => (string) $ds->getId(), 'name' => $ds->getName(),
], $this->services->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'free_turn' => 'نوبت آزادی موجود نیست',
'hours_of_work' => 'برنامه کاری تنظیم نشده',
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
'average_rate' => ['total_rates' => null],
'state' => array_map(fn(Province $p) => [