From fd59eb57a3e44d8c1323e9553c4c9dc48851ae98 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 12:42:42 +0330 Subject: [PATCH] feat(appointments): only doctors with a weekly schedule get a tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit my/clinic-doctors now reports has_schedule per doctor, and the appointments page builds tabs from it. A doctor with no working days had a tab that could only ever show an empty timeline. The flag is resolved with one query for the whole list rather than one per doctor. Clinic owners now read this authenticated endpoint too instead of the public clinic doctor-list, which is where the flag lives; admin keeps the public list and, with no flag present, hides nobody. Also repairs fallout from making the resource supervisor mandatory: four test classes build resources through their own helpers and were failing with 422. The supervisorFor helper moved to ApiTestCase so all domains share one, rather than copying it per suite. Full backend suite is green again (1306 tests) — the previous commit only ran tests/Resource and missed this. Co-Authored-By: Claude Opus 5 (1M context) --- assets/admin/pages/AppointmentsPage.test.tsx | 19 ++++++++++-- assets/admin/pages/AppointmentsPage.tsx | 17 +++++++---- docs/api/appointment.md | 17 +++++++++++ .../Controller/MyAppointmentsController.php | 21 +++++++++++-- tests/ApiTestCase.php | 30 +++++++++++++++++++ tests/Appointment/AppointmentPlanTest.php | 1 + tests/Appointment/AvailabilityEngineTest.php | 4 +++ tests/Appointment/HoldAndBookTest.php | 1 + tests/Pricing/PricingTest.php | 14 +++++---- tests/Resource/ResourceTestCase.php | 28 ----------------- 10 files changed, 107 insertions(+), 45 deletions(-) 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);