diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx index ac7c6279..826d0ede 100644 --- a/assets/admin/pages/AppointmentsPage.test.tsx +++ b/assets/admin/pages/AppointmentsPage.test.tsx @@ -61,9 +61,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic1' } as any); get.mockImplementation((url: string) => { if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 5, completed: 1, waiting: 3, cancelled: 1 } }); - if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [ - { uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' }, - ] } }); + // کلینیک هم از اندپوینت احرازشده می‌خواند؛ `has_schedule` مبنای ساختن تب است. + if (url.includes('/my/clinic-doctors') || url.includes('/clinic/doctor-list/')) { + return Promise.resolve({ success: true, data: { data: [ + { uuid: 'd1', name: 'دکتر محمدی', has_schedule: true }, + { uuid: 'd2', name: 'دکتر رضایی', has_schedule: true }, + { uuid: 'd3', name: 'دکتر بی‌برنامه', has_schedule: false }, + ] } }); + } if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } }); if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } }); // منابع زیر نظر پزشک‌اند؛ تب هر منبع فقط زیر ناظرِ خودش دیده می‌شود. @@ -108,6 +113,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false); }); + /** پزشکی که در تنظیمات نوبت‌دهی روز کاری تعریف نکرده تب نمی‌گیرد. */ + it('پزشک بدون برنامهٔ کاری تب نمی‌گیرد', async () => { + renderWithProviders(); + expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument(); + expect(screen.getByText('دکتر رضایی')).toBeInTheDocument(); + expect(screen.queryByText('دکتر بی‌برنامه')).toBeNull(); + }); + it('auto-selects the first doctor so the timeline loads its slots', async () => { renderWithProviders(); await screen.findByText('دکتر محمدی'); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 09b5891e..fccb3669 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -595,12 +595,13 @@ export default function AppointmentsPage() { const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE); // ── Clinic doctors (authoritative list for tabs) - const clinicDoctorsQuery = useQuery>({ - queryKey: ['clinic-doctors', dbUuid, isSecretary], - // منشی از اندپوینتِ احرازشده می‌گیرد تا فقط پزشکانِ تخصیص‌یافته‌اش بیایند — - // چه کلینیک چندپزشکه و چه پزشک مستقل؛ کلینیک/ادمین از لیستِ عمومیِ کلینیک. + const clinicDoctorsQuery = useQuery>({ + queryKey: ['clinic-doctors', dbUuid, isSecretary, isClinic], + // منشی و کلینیک از اندپوینتِ احرازشده می‌گیرند: هم فقط پزشکانِ مجاز را می‌دهد و هم + // `has_schedule` را، که مبنای ساختن تب است. ادمین از لیستِ کلینیکِ انتخاب‌شده + // می‌خواند (آن اندپوینت نقش ادمین را پوشش نمی‌دهد) و آنجا فلگ نمی‌آید. queryFn: () => api.get( - isSecretary + isSecretary || isClinic ? '/api/v1/my/clinic-doctors' : `/api/v1/clinic/doctor-list/${dbUuid}`, ), @@ -610,7 +611,11 @@ export default function AppointmentsPage() { const doctors = React.useMemo(() => { const map = new Map(); - clinicDoctorsList.forEach(d => map.set(d.uuid, d.name)); + // پزشکِ بدون برنامهٔ کاری تب نمی‌گیرد — تبش جز یک تایم‌لاین همیشه‌خالی چیزی ندارد. + // نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس پنهان نمی‌شود. + clinicDoctorsList + .filter(d => d.has_schedule !== false) + .forEach(d => map.set(d.uuid, d.name)); appointments.forEach(a => { if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) { map.set(a.doctor_uuid, a.doctor_name); diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 49895ba4..9814e3f2 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -1085,6 +1085,23 @@ New query param `reserve=1` → returns only reserve-list entries; without it on | `service_total_minutes` | int\|null | مدت ثبت‌شده؛ در حالت اسلاتی `null` | | `service_buffer_minutes` | int\|null | بافر مؤثر لحظهٔ ثبت | +### `has_schedule` روی فهرست پزشکان (2026-08) + +`GET /api/v1/my/clinic-doctors` برای هر پزشک `has_schedule` می‌دهد: آیا در محیط جاری +`WeeklySchedule` دارد یا نه. صفحهٔ نوبت‌ها فقط برای پزشکِ دارای برنامه تب می‌سازد — +تبِ پزشکی که روز کاری ندارد جز یک تایم‌لاین همیشه‌خالی چیزی نشان نمی‌دهد. + +با یک کوئری برای کل فهرست گرفته می‌شود (`findByDoctors`)، نه یکی به‌ازای هر پزشک. + +```json +{ "success": true, "data": { "data": [ + { "uuid": "631e81d8-…", "name": "امیر کاظمی", "has_schedule": true } +] } } +``` + +`GET /api/v1/clinic/doctor-list/{clinicUuid}` این فیلد را **ندارد** (عمومی است و نقش +ادمین را سرو می‌کند)؛ کلاینت نبودِ فیلد را «نمی‌دانیم» می‌گیرد و پزشک را پنهان نمی‌کند. + ### فیلتر و فیلدِ منبع (2026-08) صفحهٔ نوبت‌ها برای هر منبع تبِ مستقل دارد، پس فهرست باید بتواند «نوبت‌های همین دستگاه/اتاق» diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 39d78051..1a22a87b 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -46,6 +46,7 @@ class MyAppointmentsController extends BaseController private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly VisitPriceRequirementResolver $visitPriceResolver, private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, + private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo, ) {} /** @@ -77,12 +78,28 @@ class MyAppointmentsController extends BaseController $doctors = $doctor !== null ? [$doctor] : []; } + /** + * `has_schedule`: آیا این پزشک در محیط جاری برنامهٔ کاری تعریف کرده؟ + * + * صفحهٔ نوبت‌ها فقط برای پزشکِ دارای برنامه تب می‌سازد — تبِ پزشکی که روز کاری + * ندارد جز یک تایم‌لاین همیشه‌خالی چیزی نشان نمی‌دهد. یک کوئری برای کل فهرست + * گرفته می‌شود، نه یکی به‌ازای هر پزشک. + */ + $scheduledDoctorIds = []; + foreach ($this->scheduleRepo->findByDoctors($doctors) as $schedule) { + $scheduledDoctorIds[(int) $schedule->getDoctor()->getId()] = true; + } + $data = array_map( - static fn(Doctor $d) => ['uuid' => $d->getUuid(), 'name' => $d->getName()], + static fn(Doctor $d) => [ + 'uuid' => $d->getUuid(), + 'name' => $d->getName(), + 'has_schedule' => isset($scheduledDoctorIds[(int) $d->getId()]), + ], $doctors, ); - return $this->success(['data' => $data]); + return $this->success(['data' => array_values($data)]); } #[Route('/api/v1/my/appointment', methods: ['POST'])] diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 37931eb9..b0158b22 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -24,6 +24,8 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; * users and issue JWTs so tests can hit authenticated /api and /oauth endpoints. * Runs against the dedicated db_test database (see .env.test / doctrine when@test). */ +use App\Doctor\Entity\DoctorAddress; + abstract class ApiTestCase extends WebTestCase { /** @@ -255,4 +257,32 @@ abstract class ApiTestCase extends WebTestCase return array_sum(array_map('count', $holder->getData())); } + /** + * پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع + * یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته می‌شود و برای مطب، خودِ پزشک. + */ + protected function supervisorFor(DoctorAddress $address): Doctor + { + if ($address->tenantEntityType() === 'doctor') { + return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId()); + } + + $clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId()); + + foreach ($clinic->getDoctors() as $existing) { + return $existing; + } + + $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'پزشک ناظر'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + return $doctor; + } + } diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php index 366b2ad5..ac54763d 100644 --- a/tests/Appointment/AppointmentPlanTest.php +++ b/tests/Appointment/AppointmentPlanTest.php @@ -65,6 +65,7 @@ class AppointmentPlanTest extends ApiTestCase { $body = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $type->getUuid(), 'name' => $name, ]); diff --git a/tests/Appointment/AvailabilityEngineTest.php b/tests/Appointment/AvailabilityEngineTest.php index 1b41ac4e..1dd10bad 100644 --- a/tests/Appointment/AvailabilityEngineTest.php +++ b/tests/Appointment/AvailabilityEngineTest.php @@ -74,6 +74,7 @@ class AvailabilityEngineTest extends ApiTestCase { $created = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $type->getUuid(), 'name' => $name, ]); @@ -244,6 +245,7 @@ class AvailabilityEngineTest extends ApiTestCase $created = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $room->getUuid(), 'name' => 'اتاق سه‌تخته', 'capacity' => 3, @@ -297,6 +299,7 @@ class AvailabilityEngineTest extends ApiTestCase // منبع هست ولی هیچ شیفتی ندارد. $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $room->getUuid(), 'name' => 'اتاق بی‌شیفت', ]); @@ -350,6 +353,7 @@ class AvailabilityEngineTest extends ApiTestCase $created = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $device->getUuid(), 'name' => 'لیزر تنها', 'setup_minutes' => 10, diff --git a/tests/Appointment/HoldAndBookTest.php b/tests/Appointment/HoldAndBookTest.php index 28d86c98..c4b1ac6f 100644 --- a/tests/Appointment/HoldAndBookTest.php +++ b/tests/Appointment/HoldAndBookTest.php @@ -106,6 +106,7 @@ class HoldAndBookTest extends ApiTestCase { $created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ 'address_uuid' => $address->getUuid(), + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), 'type_uuid' => $type->getUuid(), 'name' => $name, ]); diff --git a/tests/Pricing/PricingTest.php b/tests/Pricing/PricingTest.php index ff50968d..227edf6e 100644 --- a/tests/Pricing/PricingTest.php +++ b/tests/Pricing/PricingTest.php @@ -259,9 +259,10 @@ class PricingTest extends ApiTestCase $this->em->flush(); $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ - 'address_uuid' => $c['address']->getUuid(), - 'type_uuid' => $room->getUuid(), - 'name' => 'اتاق ۱', + 'address_uuid' => $c['address']->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق ۱', + 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(), ]); self::assertSame(201, $this->responseCode()); @@ -344,9 +345,10 @@ class PricingTest extends ApiTestCase $this->em->flush(); $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ - 'address_uuid' => $c['address']->getUuid(), - 'type_uuid' => $room->getUuid(), - 'name' => 'اتاق ویزیت', + 'address_uuid' => $c['address']->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق ویزیت', + 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(), ]); self::assertSame(201, $this->responseCode()); diff --git a/tests/Resource/ResourceTestCase.php b/tests/Resource/ResourceTestCase.php index 5cb1fdb8..3e67f6da 100644 --- a/tests/Resource/ResourceTestCase.php +++ b/tests/Resource/ResourceTestCase.php @@ -61,34 +61,6 @@ abstract class ResourceTestCase extends ApiTestCase return [$user, $doctor, $address]; } - /** - * پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع - * یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته می‌شود و برای مطب، خودِ پزشک. - */ - protected function supervisorFor(DoctorAddress $address): Doctor - { - if ($address->tenantEntityType() === 'doctor') { - return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId()); - } - - $clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId()); - - foreach ($clinic->getDoctors() as $existing) { - return $existing; - } - - $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($user, 'پزشک ناظر'); - $doctor->setMobileNumber($user->getMobileNumber()); - $this->em->persist($doctor); - $this->em->flush(); - - $clinic->getDoctors()->add($doctor); - $this->em->flush(); - - return $doctor; - } - protected function resourceType(DoctorAddress $address, string $code = 'device', string $name = 'دستگاه'): ResourceType { $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);