diff --git a/assets/admin/components/appointments/ResourceTimeline.test.tsx b/assets/admin/components/appointments/ResourceTimeline.test.tsx new file mode 100644 index 00000000..6c2113f3 --- /dev/null +++ b/assets/admin/components/appointments/ResourceTimeline.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; +import ResourceTimeline from './ResourceTimeline'; +import type { ResourceTimelineLane } from '../../hooks/useResourceTimeline'; + +/** نیمه‌شبِ یک روز ثابت، تا موقعیت بلوک‌ها به ساعت اجرای تست وابسته نباشد. */ +const DAY_START = Math.floor(new Date(2026, 7, 5, 0, 0, 0).getTime() / 1000); +const at = (hour: number, minute = 0) => DAY_START + hour * 3600 + minute * 60; + +function lane(overrides: Partial = {}): ResourceTimelineLane { + return { + uuid: 'r-1', + name: 'لیزر دایود', + type_name: 'دستگاه لیزر', + address_name: 'شعبهٔ مرکزی', + capacity: 1, + shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }], + items: [ + { + uuid: 'o-1', + starts_at: at(10), + ends_at: at(11), + status: 'booked', + segment_name: 'لیزر', + appointment_id: 47, + patient_name: 'زهرا احمدی', + appointment_uuid: 'appt-1', + appointment_status: 'confirmed', + }, + ], + ...overrides, + }; +} + +describe('ResourceTimeline', () => { + it('یک ردیف به‌ازای هر منبع با نام بیمار و بخش نشان می‌دهد', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('لیزر دایود')).toBeInTheDocument(); + expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument(); + expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument(); + }); + + it('بلوک به جزئیات همان نوبت لینک می‌دهد', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('زهرا احمدی · لیزر').closest('a')) + .toHaveAttribute('href', '/admin/appointments/appt-1'); + }); + + /** + * یک نوبت می‌تواند هم‌زمان اتاق و دستگاه را بگیرد؛ همین است که ظرفیت را تمام می‌کند و + * نمای پزشک‌محور نشانش نمی‌دهد. + */ + it('یک نوبت روی چند منبع، روی هر ردیف دیده می‌شود', () => { + const room = lane({ + uuid: 'r-2', + name: 'اتاق لیزر ۱', + type_name: 'اتاق', + items: [{ ...lane().items[0], uuid: 'o-2', segment_name: 'بی‌حسی موضعی' }], + }); + + renderWithProviders( + , + ); + + expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument(); + expect(screen.getByText('زهرا احمدی · بی‌حسی موضعی')).toBeInTheDocument(); + }); + + it('روزِ بدون شیفت و بدون نوبت را صریح می‌گوید', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('این روز شیفتی ندارد')).toBeInTheDocument(); + }); + + it('بدون هیچ منبعی به صفحهٔ تعریف منبع راه می‌دهد', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('تنظیمات ← منابع').closest('a')).toHaveAttribute('href', '/admin/resources'); + }); + + it('خطای سرور را نشان می‌دهد، نه تایم‌لاین خالی', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/appointments/ResourceTimeline.tsx b/assets/admin/components/appointments/ResourceTimeline.tsx new file mode 100644 index 00000000..292d6876 --- /dev/null +++ b/assets/admin/components/appointments/ResourceTimeline.tsx @@ -0,0 +1,207 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import type { ResourceTimelineLane, ResourceTimelineItem } from '../../hooks/useResourceTimeline'; + +const HOUR = 60; +const LANE_HEIGHT = 46; +const LABEL_WIDTH = 168; + +function hhmm(timestamp: number): string { + const d = new Date(timestamp * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function minuteOfDay(timestamp: number, dayStart: number): number { + return Math.round((timestamp - dayStart) / 60); +} + +/** + * بازهٔ ساعتی که نشان داده می‌شود: از شیفت‌ها و اشغال‌های همان روز درمی‌آید، نه ۰ تا ۲۴. + * + * نمایش کل شبانه‌روز یعنی کلینیکی که ۱۶ تا ۲۱ کار می‌کند، پنج‌ششمِ عرض صفحه‌اش خالی + * بماند و بلوک‌ها آن‌قدر باریک شوند که خوانده نشوند. + */ +function windowOf(lanes: ResourceTimelineLane[], dayStart: number): { from: number; to: number } { + let from = 24 * HOUR; + let to = 0; + + lanes.forEach((lane) => { + lane.shifts.forEach((s) => { + from = Math.min(from, s.start_minute); + to = Math.max(to, s.end_minute); + }); + lane.items.forEach((i) => { + from = Math.min(from, minuteOfDay(i.starts_at, dayStart)); + to = Math.max(to, minuteOfDay(i.ends_at, dayStart)); + }); + }); + + if (from >= to) return { from: 8 * HOUR, to: 20 * HOUR }; + + // به ساعت گرد می‌شود تا خط‌کش بالای نمودار عدد کامل نشان دهد. + return { from: Math.floor(from / HOUR) * HOUR, to: Math.ceil(to / HOUR) * HOUR }; +} + +/** + * تایم‌لاین منبع‌محورِ یک روز — یک ردیف به‌ازای هر منبع. + * + * نمای پزشک‌محور نمی‌گوید چرا ساعتی پر است؛ در مدل منبع‌محور یک نوبت می‌تواند هم‌زمان + * اتاق و دستگاه را بگیرد و همان است که ظرفیت را تمام می‌کند. اینجا هر بلوک یک ردیفِ + * اشغال است، پس یک نوبتِ چندبخشی روی چند ردیف دیده می‌شود. + */ +export default function ResourceTimeline({ lanes, dayStart, loading, error }: { + lanes: ResourceTimelineLane[]; + dayStart: number; + loading: boolean; + error: string | null; +}) { + const { from, to } = useMemo(() => windowOf(lanes, dayStart), [lanes, dayStart]); + const span = to - from; + + const hours = useMemo( + () => Array.from({ length: Math.floor(span / HOUR) + 1 }, (_, i) => from + i * HOUR), + [from, span], + ); + + if (loading) { + return
در حال بارگذاری...
; + } + + if (error) { + return
{error}
; + } + + if (lanes.length === 0) { + return ( +
+ هنوز منبعی تعریف نشده است. +
+ تنظیمات ← منابع +
+ ); + } + + return ( +
+
+ {/* خط‌کش ساعت */} +
+
+
+ {hours.map((m) => ( + + {String(Math.floor(m / 60)).padStart(2, '0')} + + ))} +
+
+ + {lanes.map((lane) => ( +
+
+ + {lane.name} + + {lane.type_name} +
+ +
+ {/* شیفت کاری: پس‌زمینهٔ روشن‌تر، تا «تعطیل» از «آزاد» فرق کند */} + {lane.shifts.map((s, i) => ( +
+ ))} + + {hours.map((m) => ( +
+ ))} + + {lane.items.map((item) => ( + + ))} + + {lane.shifts.length === 0 && lane.items.length === 0 && ( + + این روز شیفتی ندارد + + )} +
+
+ ))} +
+
+ ); +} + +function Block({ item, dayStart, from, span }: { + item: ResourceTimelineItem; + dayStart: number; + from: number; + span: number; +}) { + const start = minuteOfDay(item.starts_at, dayStart); + const end = minuteOfDay(item.ends_at, dayStart); + const hold = item.status === 'hold'; + + const label = [item.patient_name, item.segment_name].filter(Boolean).join(' · '); + const title = `${hhmm(item.starts_at)} تا ${hhmm(item.ends_at)}${label ? ` — ${label}` : ''}`; + + const body = ( + + {label || hhmm(item.starts_at)} + + ); + + const style: React.CSSProperties = { + position: 'absolute', top: 8, height: 30, + right: `${((start - from) / span) * 100}%`, + width: `${Math.max(((end - start) / span) * 100, 1.2)}%`, + borderRadius: 6, textDecoration: 'none', + // رزرو موقت هنوز نوبت نیست: توپر نشان دادنش یعنی کاربر آن را قطعی بخواند. + background: hold ? 'var(--warning-bg)' : 'var(--primary)', + border: hold ? '1px dashed var(--warning)' : 'none', + }; + + if (!item.appointment_uuid) { + return
{body}
; + } + + return ( + + {body} + + ); +} diff --git a/assets/admin/components/appointments/TurnsViewToggle.tsx b/assets/admin/components/appointments/TurnsViewToggle.tsx index 7ab3942f..7f08478c 100644 --- a/assets/admin/components/appointments/TurnsViewToggle.tsx +++ b/assets/admin/components/appointments/TurnsViewToggle.tsx @@ -1,8 +1,17 @@ /** - * سوییچ کشویی «نمایش جدولی / زمانبندی» — بازسازی `TurnsViewModeToggle.jsx` طرح - * tauri (کادر ۱۹۳×۴۸، پس‌زمینهٔ لغزنده، متن فعال #5559ce). + * سوییچ کشویی نمای نوبت‌ها — بازسازی `TurnsViewModeToggle.jsx` طرح tauri + * (پس‌زمینهٔ لغزنده، متن فعال #5559ce). + * + * نمای «منابع» بعداً اضافه شد چون در مدل منبع‌محور، پرشدن یک ساعت را دستگاه و اتاق + * تعیین می‌کنند نه فقط برنامهٔ پزشک. */ -export type TurnsViewMode = 'table' | 'timeline'; +export type TurnsViewMode = 'table' | 'timeline' | 'resources'; + +const MODES: { id: TurnsViewMode; label: string }[] = [ + { id: 'table', label: 'نمایش جدولی' }, + { id: 'timeline', label: 'زمانبندی' }, + { id: 'resources', label: 'منابع' }, +]; export default function TurnsViewToggle({ viewMode, onChange, @@ -10,42 +19,46 @@ export default function TurnsViewToggle({ viewMode: TurnsViewMode; onChange: (m: TurnsViewMode) => void; }) { - const activeText = 'var(--primary)'; - const idleText = 'var(--text-3)'; + const index = Math.max(MODES.findIndex((m) => m.id === viewMode), 0); + const width = 100 / MODES.length; + return ( -
- {/* پس‌زمینهٔ لغزنده */} +
+ {/* پس‌زمینهٔ لغزنده — RTL، پس اولین حالت سمت راست است */}
- -
- + + {MODES.map((m, i) => ( +
+ {i > 0 && ( +
+ )} + +
+ ))}
); } diff --git a/assets/admin/hooks/useResourceTimeline.ts b/assets/admin/hooks/useResourceTimeline.ts new file mode 100644 index 00000000..7dfbea61 --- /dev/null +++ b/assets/admin/hooks/useResourceTimeline.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, type ApiResponse } from '../lib/api'; + +export type ResourceTimelineItem = { + uuid: string; + starts_at: number; + ends_at: number; + status: 'booked' | 'hold'; + segment_name: string | null; + appointment_id: number | null; + patient_name: string | null; + appointment_uuid: string | null; + appointment_status: string | null; +}; + +export type ResourceTimelineLane = { + uuid: string; + name: string; + type_name: string; + address_name: string | null; + capacity: number; + shifts: { start_minute: number; end_minute: number }[]; + items: ResourceTimelineItem[]; +}; + +export type ResourceTimelineDay = { + date: number; + day_of_week: number; + resources: ResourceTimelineLane[]; +}; + +/** + * «کدام منبع در این روز کِی گرفته است» — ورودی نمای منبع‌محورِ صفحهٔ نوبت‌ها. + * + * بازه‌ها از جدول اشغال می‌آیند نه از خودِ نوبت: آماده‌سازی و تمیزکاری دستگاه هم + * درونشان است و همان بازه‌ای است که موتور جستجو اشغال می‌بیند. + */ +export function useResourceTimeline(date: string, addressUuid?: string) { + const params = new URLSearchParams({ date }); + if (addressUuid) params.set('address_uuid', addressUuid); + + const query = useQuery({ + queryKey: ['resource-timeline', date, addressUuid ?? null], + queryFn: () => api.get>(`/api/v1/resources/timeline?${params}`), + enabled: !!date, + }); + + return { + day: query.data?.data, + loading: query.isLoading, + error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null, + }; +} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 23ceb1da..175ec8bb 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -23,6 +23,8 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import TurnsStatInfo from '../components/appointments/TurnsStatInfo'; import TurnsViewToggle from '../components/appointments/TurnsViewToggle'; import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; +import ResourceTimeline from '../components/appointments/ResourceTimeline'; +import { useResourceTimeline } from '../hooks/useResourceTimeline'; import DoctorTabs from '../components/appointments/DoctorTabs'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; @@ -616,6 +618,14 @@ export default function AppointmentsPage() { const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null; const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid; + // نمای منبع‌محور فقط وقتی درخواست می‌فرستد که انتخاب شده باشد؛ در نمای جدولی + // یک کوئری اضافه به‌ازای هر تغییر روز، بی‌مصرف است. + const { + day: resourceDay, + loading: resourceTimelineLoading, + error: resourceTimelineError, + } = useResourceTimeline(viewMode === 'resources' ? selectedDate : ''); + // ── Slots query (timeline) // effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود. const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid]; @@ -831,7 +841,14 @@ export default function AppointmentsPage() { )}
- {viewMode === 'table' ? ( + {viewMode === 'resources' ? ( + + ) : viewMode === 'table' ? ( <> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام می‌شود، پس یک ردیف نامعتبر > در انتهای فهرست، مهارت‌های درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند. +### `GET /api/v1/resources/timeline` + +مجوز: `appointment_settings.view`. پشتِ نمای **منابع** در صفحهٔ نوبت‌ها +(`/admin/appointments`) است. + +| پارامتر | پیش‌فرض | توضیح | +|---|---|---| +| `date` | امروز | `YYYY-MM-DD` — هر قالب دیگری ۴۲۲ | +| `address_uuid` | همهٔ شعبه‌ها | فقط منابع همان شعبه | + +فقط منابع **فعال** برمی‌گردند و منبعِ بی‌شیفت هم در فهرست می‌ماند تا ردیفش در تایم‌لاین +دیده شود. + +بازه‌ها از `resource_occupancy` می‌آیند نه از خودِ نوبت: بازهٔ اشغال، آماده‌سازی و +تمیزکاری منبع را هم در بر دارد و همان بازه‌ای است که موتور جستجو اشغال می‌بیند. ردیف +`released` نمی‌آید؛ آن تاریخچه است. یک نوبتِ چندبخشی روی چند منبع، چند ردیف دارد — +همان چیزی که نمای پزشک‌محور نشان نمی‌دهد. + +خروجی واقعی (سناریوی ۲، ۲۰۲۶-۰۸-۰۵): + +```jsonc +{ + "success": true, + "data": { + "date": 1785875400, // نیمه‌شب همان روز + "day_of_week": 4, // ۰ = شنبه + "resources": [ + { + "uuid": "…", "name": "اتاق لیزر ۱", "type_name": "اتاق درمان", + "address_name": "درمانگاه سلامت", "capacity": 1, + "shifts": [{ "start_minute": 480, "end_minute": 1260 }], + "items": [ + { + "uuid": "6176e73b-df27-4cdf-815c-36f9bfbd68ca", + "starts_at": 1785931200, "ends_at": 1785931500, + "status": "booked", "segment_name": "بی‌حسی موضعی", + "appointment_id": 47, "patient_name": "زهرا احمدی", + "appointment_uuid": "17086c41-6cc4-4539-bb5b-4c94e8f373c6", + "appointment_status": "pending" + } + ] + } + ] + } +} +``` + +`shifts` فقط شیفت‌های همان روزِ هفته است. **۴۲۲:** قالب `date` غلط +(`{"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}`). + +> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای +> هر منبع. + ### `PUT /api/v1/resource/{uuid}/categories` مجوز: `appointment_settings.update`. diff --git a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php index d9b0a64e..a1e86124 100644 --- a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php +++ b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php @@ -132,4 +132,58 @@ class ResourceOccupancyRepository extends ServiceEntityRepository { return $this->findBy(['appointmentId' => $appointmentId]); } + + /** + * ردیف‌های اشغالِ یک روز برای تایم‌لاین منابع — **یک کوئری برای همهٔ منابع**. + * + * برخلاف `busyByResource()` که فقط بازه می‌خواهد، اینجا شناسهٔ نوبت و نام بخش هم + * لازم است تا هر بلوک بگوید مالِ کدام بیمار و کدام مرحله است. آرایه برمی‌گردد نه + * entity، چون هیچ‌کدام از این ردیف‌ها قرار نیست تغییر کند. + * + * @param int[] $resourceIds + * @return array> + */ + public function dayByResource(array $resourceIds, int $from, int $to): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('o') + ->select( + 'IDENTITY(o.resource) AS resource_id', + 'o.uuid AS uuid', + 'o.startsAt AS starts_at', + 'o.endsAt AS ends_at', + 'o.status AS status', + 'o.segmentName AS segment_name', + 'o.appointmentId AS appointment_id', + ) + ->where('o.resource IN (:ids)') + ->andWhere('o.startsAt < :to') + ->andWhere('o.endsAt > :from') + // ردیف آزادشده تاریخچه است؛ در تایم‌لاینِ «الان چه چیزی گرفته است» جا ندارد. + ->andWhere('o.status IN (:blocking)') + ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) + ->setParameter('ids', $resourceIds) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('o.startsAt', 'ASC') + ->getQuery() + ->getArrayResult(); + + $byResource = []; + foreach ($rows as $row) { + $byResource[(int) $row['resource_id']][] = [ + 'uuid' => (string) $row['uuid'], + 'starts_at' => (int) $row['starts_at'], + 'ends_at' => (int) $row['ends_at'], + 'status' => (string) $row['status'], + 'segment_name' => $row['segment_name'] !== null ? (string) $row['segment_name'] : null, + 'appointment_id' => $row['appointment_id'] !== null ? (int) $row['appointment_id'] : null, + ]; + } + + return $byResource; + } } diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index 93c8924b..7fe2b9ac 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -31,6 +31,8 @@ class ResourceController extends BaseController private readonly SkillAssignmentService $skills, private readonly ResourceOccupancyRepository $occupancy, private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings, + private readonly \App\Resource\Repository\ResourceCalendarRepository $calendars, + private readonly \App\Appointment\Repository\AppointmentRepository $appointments, ) {} #[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] @@ -177,6 +179,135 @@ class ResourceController extends BaseController return $this->success($resource->toArray()); } + /** + * تایم‌لاین یک روز به تفکیک منبع — «کدام دستگاه/اتاق/پزشک کِی گرفته است». + * + * نمای نوبت‌ها تا امروز فقط ردیف پزشک را نشان می‌داد؛ در مدل منبع‌محور، یک نوبت + * می‌تواند هم‌زمان اتاق و دستگاه را بگیرد و همان چیزی است که ظرفیت را تمام می‌کند. + * + * ردیف اشغال از `resource_occupancy` می‌آید، نه از خودِ نوبت: بازهٔ آن شاملِ + * آماده‌سازی و تمیزکاری منبع هم هست و همان بازه‌ای است که موتور جستجو می‌بیند. + */ + #[Route('/api/v1/resources/timeline', name: 'resource_timeline', methods: ['GET'])] + public function timeline(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + [$entityType, $entityId] = $this->context->pair($user); + + $dateParam = $request->query->get('date'); + $from = $this->dayStart(is_string($dateParam) ? $dateParam : null); + + if ($from === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تاریخ باید به شکل YYYY-MM-DD باشد', 422, 'date'); + } + + $to = $from + 86400; + $addressUuid = $request->query->get('address_uuid'); + + $resources = $this->resources->findForPair($entityType, $entityId, [ + 'address' => is_string($addressUuid) && $addressUuid !== '' ? $this->context->address($user, $addressUuid) : null, + 'type' => null, + 'active' => true, + 'skillUuid' => null, + ]); + + $ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources); + + $occupancy = $this->occupancy->dayByResource($ids, $from, $to); + $shifts = $this->calendars->findForResources($ids); + $dayOfWeek = $this->dayOfWeek($from); + $patients = $this->patientLabels($occupancy); + + return $this->success([ + 'date' => $from, + 'day_of_week' => $dayOfWeek, + 'resources' => array_map(function (ClinicResource $r) use ($occupancy, $shifts, $dayOfWeek, $patients): array { + $id = (int) $r->getId(); + + return [ + 'uuid' => $r->getUuid(), + 'name' => $r->getName(), + 'type_name' => $r->getType()->getName(), + 'address_name' => $r->getAddress()->getName(), + 'capacity' => $r->getCapacity(), + 'shifts' => array_values(array_map( + static fn (\App\Resource\Entity\ResourceCalendar $c): array => [ + 'start_minute' => $c->getStartMinute(), + 'end_minute' => $c->getEndMinute(), + ], + array_filter( + $shifts[$id] ?? [], + static fn (\App\Resource\Entity\ResourceCalendar $c): bool => $c->getDayOfWeek() === $dayOfWeek, + ), + )), + 'items' => array_map( + static fn (array $row): array => $row + ($patients[$row['appointment_id']] ?? [ + 'patient_name' => null, + 'appointment_uuid' => null, + 'appointment_status' => null, + ]), + $occupancy[$id] ?? [], + ), + ]; + }, $resources), + ]); + } + + /** نیمه‌شبِ روز خواسته‌شده؛ بدون پارامتر یعنی امروز. `null` یعنی قالب تاریخ غلط بود. */ + private function dayStart(?string $date): ?int + { + if ($date === null || $date === '') { + return strtotime('today midnight') ?: null; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + return null; + } + + return strtotime($date . ' midnight') ?: null; + } + + /** ۰ = شنبه — همان قرارداد تقویم منبع و ساعت کاری شعبه. */ + private function dayOfWeek(int $timestamp): int + { + // date('w') یکشنبه را ۰ می‌گیرد؛ شنبه باید ۰ شود. + return (int) ((((int) date('w', $timestamp)) + 1) % 7); + } + + /** + * نام بیمار و وضعیت نوبتِ هر ردیف اشغال — با **یک** کوئری برای کل روز. + * + * @param array>> $occupancy + * @return array + */ + private function patientLabels(array $occupancy): array + { + $ids = []; + foreach ($occupancy as $rows) { + foreach ($rows as $row) { + if ($row['appointment_id'] !== null) { + $ids[(int) $row['appointment_id']] = true; + } + } + } + + if ($ids === []) { + return []; + } + + $labels = []; + foreach ($this->appointments->findBy(['id' => array_keys($ids)]) as $appointment) { + $labels[(int) $appointment->getId()] = [ + 'patient_name' => $appointment->getPatientName(), + 'appointment_uuid' => $appointment->getUuid(), + 'appointment_status' => $appointment->getStatus(), + ]; + } + + return $labels; + } + /** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */ #[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])] public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse diff --git a/tests/Resource/ResourceTimelineEndpointTest.php b/tests/Resource/ResourceTimelineEndpointTest.php new file mode 100644 index 00000000..44dda1ec --- /dev/null +++ b/tests/Resource/ResourceTimelineEndpointTest.php @@ -0,0 +1,161 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک تایم‌لاین'); + $this->em->persist($clinic); + $this->em->flush(); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + $this->em->flush(); + + $type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه لیزر'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'لیزر دایود'); + $this->em->persist($resource); + $this->em->flush(); + + return [$user, $resource]; + } + + private function occupy(ClinicResource $resource, int $start, int $end, string $segment): void + { + $occupancy = new ResourceOccupancy($resource, $start, $end); + $occupancy->setSegmentName($segment); + $this->em->persist($occupancy); + $this->em->flush(); + } + + private function shift(ClinicResource $resource, int $day, int $from, int $to): void + { + $this->em->persist(new ResourceCalendar($resource, $day, $from, $to)); + $this->em->flush(); + } + + /** ۰ = شنبه، همان قرارداد تقویم منبع. */ + private function dayOfWeek(int $timestamp): int + { + return (int) (((int) date('w', $timestamp) + 1) % 7); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testTheDayShowsEachResourceWithItsOccupiedRanges(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $this->shift($resource, $this->dayOfWeek($midnight), 540, 1020); + $this->occupy($resource, $midnight + 10 * 3600, $midnight + 11 * 3600, 'لیزر'); + + $body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($midnight, $body['data']['date']); + + $row = $body['data']['resources'][0]; + self::assertSame('لیزر دایود', $row['name']); + self::assertSame('دستگاه لیزر', $row['type_name']); + self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $row['shifts']); + + self::assertCount(1, $row['items']); + self::assertSame('لیزر', $row['items'][0]['segment_name']); + self::assertSame($midnight + 10 * 3600, $row['items'][0]['starts_at']); + } + + public function testOnlyTheShiftsOfThatWeekdayComeBack(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $today = $this->dayOfWeek($midnight); + + $this->shift($resource, $today, 540, 1020); + // شیفت روز دیگر نباید در تایم‌لاین امروز ظاهر شود. + $this->shift($resource, ($today + 3) % 7, 60, 120); + + $body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user); + + self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $body['data']['resources'][0]['shifts']); + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + public function testARestDayReturnsTheResourceWithNothingOnIt(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $this->occupy($resource, $midnight - 5 * 86400, $midnight - 5 * 86400 + 3600, 'هفتهٔ پیش'); + + $body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user); + + // منبع می‌ماند تا ردیفش در تایم‌لاین دیده شود؛ فقط خالی است. + self::assertCount(1, $body['data']['resources']); + self::assertSame([], $body['data']['resources'][0]['items']); + self::assertSame([], $body['data']['resources'][0]['shifts']); + } + + public function testWithoutADateItFallsBackToToday(): void + { + [$user] = $this->clinicWithResource(); + + $body = $this->authJson('GET', '/api/v1/resources/timeline', $user); + + self::assertSame(200, $this->responseCode()); + self::assertSame((int) strtotime('today midnight'), $body['data']['date']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAMalformedDateIsRefused(): void + { + [$user] = $this->clinicWithResource(); + + $this->authJson('GET', '/api/v1/resources/timeline?date=05-08-2026', $user); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnotherEnvironmentDoesNotSeeTheseResources(): void + { + [, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $this->occupy($resource, $midnight + 3600, $midnight + 7200, 'لیزر'); + + [$stranger] = $this->clinicWithResource(); + + $body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $stranger); + + // منبعِ خودش را می‌بیند، نه منبع کلینیک دیگر. + self::assertCount(1, $body['data']['resources']); + self::assertSame([], $body['data']['resources'][0]['items']); + } +}