feat(appointments): only doctors with a weekly schedule get a tab
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(<AppointmentsPage />);
|
||||
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(<AppointmentsPage />);
|
||||
await screen.findByText('دکتر محمدی');
|
||||
|
||||
@@ -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<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||
queryKey: ['clinic-doctors', dbUuid, isSecretary],
|
||||
// منشی از اندپوینتِ احرازشده میگیرد تا فقط پزشکانِ تخصیصیافتهاش بیایند —
|
||||
// چه کلینیک چندپزشکه و چه پزشک مستقل؛ کلینیک/ادمین از لیستِ عمومیِ کلینیک.
|
||||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string; has_schedule?: boolean }[] }>>({
|
||||
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<string, string>();
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
صفحهٔ نوبتها برای هر منبع تبِ مستقل دارد، پس فهرست باید بتواند «نوبتهای همین دستگاه/اتاق»
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user