diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx new file mode 100644 index 00000000..efc02f2e --- /dev/null +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -0,0 +1,429 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../../lib/api'; +import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../../lib/utils'; +import { useAuthStore } from '../../stores/authStore'; +import Modal from '../ui/Modal'; +import PriceInput from '../ui/PriceInput'; +import ServiceSlotPicker from './ServiceSlotPicker'; +import type { ServicePick } from './ServiceSlotPicker'; +import type { BookingService } from '../../hooks/useDoctorBookingServices'; + +export interface BookingSlot { + start: number; + end: number; + start_time: string; + end_time: string; + doctor_uuid: string; + doctor_name: string; +} + +interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } + +/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */ +export interface BookingResource { + uuid: string; + name: string; +} + +/** + * ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویس‌ها و زمان، هزینهٔ ویزیت. + * + * سه حالت دارد و هر سه یک فرم‌اند — سه مودال یعنی سه رفتار: + * - اسلاتی: زمان همان اسلاتِ کلیک‌شده است. + * - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک می‌آید. + * - منبع: همان انتخابگر، ولی زمان‌ها از تقویم خودِ منبع و ثبت با `resource_uuid`. + */ +export default function NewAppointmentModal({ + slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null, +}: { + slot: BookingSlot; + onClose: () => void; + onSuccess: () => void; + serviceMode?: boolean; + services?: BookingService[]; + date?: string; + /** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */ + clinicUuid?: string | null; + resource?: BookingResource | null; +}) { + const [mobile, setMobile] = useState(''); + const [lookup, setLookup] = useState(null); + const [patientName, setPatientName] = useState(''); + const [nationalCode, setNationalCode] = useState(''); + // معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل. + const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national'); + const [pick, setPick] = useState({ serviceUuids: [], durations: {}, slot: null }); + + const role = useAuthStore(s => s.primaryRole); + // نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد. + const createEndpoint = role === 'admin' && resource === null + ? '/api/v1/admin/appointment' + : '/api/v1/my/appointment'; + + // منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد + // که بشود رویش نشست. + const pickerMode = serviceMode || resource !== null; + + // هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان، + // API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. + // + // قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛ + // منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن + // پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود. + type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } }; + const doctorPricingQ = useQuery({ + queryKey: ['insurance-pricing', slot.doctor_uuid], + queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`), + enabled: !!slot.doctor_uuid, + retry: false, + }); + const selfPricingQ = useQuery({ + queryKey: ['insurance-pricing'], + queryFn: () => api.get('/api/v1/insurance-pricing'), + enabled: doctorPricingQ.isError || !slot.doctor_uuid, + }); + const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any; + const freeVisit = pricing?.data?.free_visit_price_rials ?? 0; + const requireVisit = pricing?.data?.require_visit_price ?? false; + const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading; + const [visitPriceToman, setVisitPriceToman] = useState(0); + const [visitPriceTouched, setVisitPriceTouched] = useState(false); + useEffect(() => { + if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); + }, [freeVisit, visitPriceTouched]); + // هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است. + const [visitPriceOpen, setVisitPriceOpen] = useState(false); + const visitPriceExpanded = requireVisit || visitPriceOpen; + + const mobileValid = /^09\d{9}$/.test(mobile); + const nationalCodeValid = /^\d{10}$/.test(nationalCode); + // اعتبار کلید جستجو بسته به معیار انتخاب‌شده. + const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid; + const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot); + // یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است. + const foundWithNationalCode = !!lookup?.found && !!lookup.national_code; + const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی + + const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim(); + const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode; + const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10; + const visitPriceValid = !requireVisit || visitPriceToman > 0; + const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid; + + const search = useMutation({ + mutationFn: () => { + const q = searchBy === 'mobile' + ? `mobile=${encodeURIComponent(mobile)}` + : `national_code=${encodeURIComponent(nationalCode)}`; + return api.get(`/api/v1/my/appointment/patient-lookup?${q}`); + }, + onSuccess: (res: any) => { + const data: PatientLookup = res?.data ?? { found: false }; + setLookup(data); + setPatientName(data.found ? (data.name ?? '') : ''); + // موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند. + if (data.found) { + if (data.mobile) setMobile(data.mobile); + setNationalCode(data.national_code ?? ''); + } else if (searchBy === 'mobile') { + setNationalCode(''); + } + }, + onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'), + }); + + const mutation = useMutation({ + mutationFn: () => api.post(createEndpoint, { + ...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}), + slot_start: pickerMode ? pick.slot!.start : slot.start, + slot_end: pickerMode ? pick.slot!.end : slot.end, + patient_mobile: mobile, + patient_name: effectiveName, + patient_national_code: effectiveNationalCode, + ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), + ...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}), + // منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست. + ...(resource ? { + resource_uuid: resource.uuid, + duration_from_services: true, + service_durations: pick.durations, + } : {}), + ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), + }), + onSuccess: () => { + toast.success('نوبت با موفقیت ثبت شد'); + onSuccess(); + onClose(); + }, + onError: (e: any) => { + const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت'; + toast.error(msg); + }, + }); + + // تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. + function invalidateLookup() { + if (lookup !== null) { setLookup(null); setPatientName(''); } + } + function onMobileChange(v: string) { + // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). + setMobile(sanitizeMobileInput(v)); + if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); } + } + function onNationalSearchChange(v: string) { + setNationalCode(digitsOnly(v, 10)); + invalidateLookup(); + } + // جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند. + function onSwitchSearchBy(mode: 'mobile' | 'national') { + setSearchBy(mode); + setLookup(null); setPatientName(''); + setMobile(''); setNationalCode(''); + } + + const priceHint = pricingLoading + ? 'در حال خواندن تعرفهٔ پزشک…' + : freeVisit > 0 + ? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید` + : 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید'; + + return ( + + + + + } + > + {/* هدف نوبت: منبع، یا اسلات/پزشک */} +
+ {resource + ? + : } + + {resource ? resource.name : pickerMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`} + + {(resource || !pickerMode) && slot.doctor_name && ( + + {slot.doctor_name} + + )} +
+ + {pickerMode && date && ( +
+ +
+ )} + +
+ + {/* انتخاب معیار جستجو: موبایل یا کد ملی */} +
+ {(['national', 'mobile'] as const).map(mode => ( + + ))} +
+
+
+ {searchBy === 'mobile' ? ( + onMobileChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} + placeholder="مثال: 09123456789" + style={{ direction: 'ltr' }} + autoFocus + /> + ) : ( + onNationalSearchChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} + placeholder="کد ملی ۱۰ رقمی" + style={{ direction: 'ltr' }} + autoFocus + /> + )} +
+ +
+
+ + {foundWithNationalCode && ( +
+ +
+
بیمار یافت شد
+
{lookup?.name}
+
کد ملی: {lookup?.national_code}
+
+
+ )} + + {needsDetails && ( + <> +
+ {lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'} +
+
+ +
+ setPatientName(e.target.value)} + placeholder="مثال: علی محمدی" + /> +
+
+ {/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */} + {searchBy === 'national' && ( +
+ +
+ setMobile(sanitizeMobileInput(e.target.value))} + placeholder="مثال: 09123456789" + style={{ direction: 'ltr' }} + /> +
+
+ )} + {/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */} + {searchBy === 'mobile' && ( +
+ +
+ setNationalCode(digitsOnly(e.target.value, 10))} + placeholder="کد ملی ۱۰ رقمی" + style={{ direction: 'ltr' }} + /> +
+
+ )} + + )} + +
+ {requireVisit ? ( + + ) : ( + // سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است). + + )} + {visitPriceExpanded && ( + <> +
+ { setVisitPriceToman(v); setVisitPriceTouched(true); }} + suffix="تومان" + /> +
+ {requireVisit && visitPriceToman <= 0 + ? هزینه ویزیت الزامی است + : {priceHint}} + {freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && ( + + )} + + )} +
+
+ ); +} diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx new file mode 100644 index 00000000..aac03463 --- /dev/null +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('../../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../../lib/api'; +import { useAuthStore } from '../../stores/authStore'; +import NewAppointmentModal from './NewAppointmentModal'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +const START = 1_900_000_000; + +const slot = { + start: 0, end: 0, start_time: '', end_time: '', + doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی', +}; + +const resource = { uuid: 'r-2', name: 'لیزر CO2' }; + +const services = [ + { uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } }, + { uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } }, +]; + +/** بیمارِ یافت‌شده تا فرم معتبر شود؛ زمان‌های خالی از تقویم منبع. */ +function mockApi() { + get.mockImplementation((url: string) => { + if (url.includes('patient-lookup')) { + return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } }); + } + if (url.includes('/service-slots')) { + return Promise.resolve({ success: true, data: { + total_duration_minutes: 25, + start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }], + } }); + } + return Promise.resolve({ success: true, data: {} }); + }); +} + +/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */ +async function pickServiceAndTime() { + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText('خدمات درمانگاه')); + fireEvent.click(await screen.findByRole('button', { name: /کرایوتراپی/ })); + fireEvent.click(await screen.findByRole('button', { name: '12:00' })); +} + +beforeEach(() => { + get.mockReset(); + post.mockReset(); + useAuthStore.setState({ primaryRole: 'clinic' } as any); + post.mockResolvedValue({ success: true, data: { uuid: 'new1' } }); + mockApi(); +}); + +describe('مودال ثبت نوبتِ منبع', () => { + it('نام منبع و پزشک ناظر بالای فرم می‌آید و زمان‌ها از تقویم منبع خوانده می‌شوند', async () => { + renderWithProviders( + {}} onSuccess={() => {}} />, + ); + + expect(screen.getByText('لیزر CO2')).toBeInTheDocument(); + expect(screen.getByText('مینا یوسفی')).toBeInTheDocument(); + + await pickServiceAndTime(); + + await waitFor(() => expect( + get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')), + ).toBe(true)); + }); + + it('ثبت، نوبت را با resource_uuid و مدتِ سرویس‌ها می‌فرستد', async () => { + renderWithProviders( + {}} onSuccess={() => {}} />, + ); + + await pickServiceAndTime(); + + fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); + fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); + await screen.findByText('بیمار یافت شد'); + + fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); + + await waitFor(() => expect(post).toHaveBeenCalled()); + const [url, body] = post.mock.calls[0]; + expect(url).toBe('/api/v1/my/appointment'); + expect(body).toMatchObject({ + resource_uuid: 'r-2', + duration_from_services: true, + service_item_uuids: ['s-1'], + service_durations: { 's-1': 25 }, + slot_start: START, + patient_national_code: '0012345678', + }); + }); + + it('بدون انتخاب سرویس و زمان، ثبت غیرفعال می‌ماند', async () => { + renderWithProviders( + {}} onSuccess={() => {}} />, + ); + + fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); + fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); + await screen.findByText('بیمار یافت شد'); + + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + }); + + it('منبعِ بدون سرویس، پیام راهنما می‌دهد نه فهرست خالی', () => { + renderWithProviders( + {}} onSuccess={() => {}} />, + ); + + expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/appointments/ResourceDayPanel.test.tsx b/assets/admin/components/appointments/ResourceDayPanel.test.tsx new file mode 100644 index 00000000..e10f4169 --- /dev/null +++ b/assets/admin/components/appointments/ResourceDayPanel.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('../../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../../lib/api'; +import ResourceDayPanel from './ResourceDayPanel'; +import type { Appointment, ClinicResource } from '../../types'; + +const get = api.get as ReturnType; + +const DAY = Math.floor(Date.now() / 1000) + 7 * 86400; + +const resource = { + uuid: 'r-2', name: 'لیزر CO2', + supervisor: { uuid: 'd1', name: 'مینا یوسفی' }, +} as unknown as ClinicResource; + +const appointment = { + uuid: 'a-1', patient_name: 'رضا رحیمی', patient_mobile: '09120001307', + doctor_uuid: 'd1', doctor_name: 'مینا یوسفی', + slot_start: DAY + 3600, slot_end: DAY + 3600 + 3000, + appointment_date: '', appointment_time: '10:35', end_time: '11:25', + status: 'pending', version: 1, created_at: '', + service_item: { uuid: 's-2', name: 'RF فرکشنال' }, +} as unknown as Appointment; + +function render(appointments: Appointment[] = []) { + return renderWithProviders( + {}} + onView={() => {}} + />, + ); +} + +beforeEach(() => get.mockReset()); + +describe('ResourceDayPanel', () => { + it('بازهٔ کاری منبع و ردیف‌های خالیِ قابل رزرو را نشان می‌دهد', async () => { + get.mockResolvedValue({ success: true, data: { + windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }], + empty_reason: null, + } }); + + render(); + + expect(await screen.findByText(/ساعت کاری: 08:00 - 14:00/)).toBeInTheDocument(); + expect(screen.getByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument(); + expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument(); + }); + + it('نوبتِ رزروشده کارت خودش را می‌گیرد و خالی‌ها دو طرفش می‌مانند', async () => { + get.mockResolvedValue({ success: true, data: { + windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }], + empty_reason: null, + } }); + + render([appointment]); + + expect(await screen.findByText('رضا رحیمی')).toBeInTheDocument(); + expect(screen.getByText('سرویس: RF فرکشنال')).toBeInTheDocument(); + expect(screen.getAllByText('افزودن نوبت سریع')).toHaveLength(2); + }); + + it('روزِ بدون شیفت، دلیلش را می‌گوید نه فهرست خالی', async () => { + get.mockResolvedValue({ success: true, data: { windows: [], empty_reason: 'no_shift' } }); + + render(); + + expect(await screen.findByText('این روز شیفت کاری ندارد')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/appointments/ResourceDayPanel.tsx b/assets/admin/components/appointments/ResourceDayPanel.tsx index 51b44ad1..72cf303b 100644 --- a/assets/admin/components/appointments/ResourceDayPanel.tsx +++ b/assets/admin/components/appointments/ResourceDayPanel.tsx @@ -1,34 +1,53 @@ import React from 'react'; -import { PlusIcon } from '@heroicons/react/24/outline'; -import { formatNumber } from '../../lib/utils'; -import StatusBadge from '../ui/StatusBadge'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import TurnsTimeline from './TurnsTimeline'; +import { buildServiceTimeline } from './serviceTimeline'; +import type { TimelineSlot } from './types'; import type { Appointment, ClinicResource } from '../../types'; -function hhmm(ts: number): string { - const d = new Date(ts * 1000); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +interface DaySlotsData { + windows: { start: number; end: number; start_time: string; end_time: string }[]; + empty_reason: string | null; } /** - * نمای روزِ یک منبع. + * نمای روزِ یک منبع — همان تایم‌لاینِ نوبت‌دهی سرویسیِ پزشک، با تقویم خودِ منبع. * - * عمداً تایم‌لاین اسلاتی نیست: منبع برنامهٔ هفتگیِ اسلات‌شده ندارد، تقویمش از مدتِ - * سرویس‌ها و اشغالِ واقعی ساخته می‌شود. نشان دادن شبکهٔ اسلات برای منبع یعنی وعدهٔ - * زمان‌هایی که موتور رزرو اصلاً نمی‌شناسد. - * - * پس فقط دو چیز: آنچه امروز روی این منبع رزرو شده، و یک راه برای افزودن نوبتِ سرویسی. + * منبع اسلاتِ ثابت ندارد: بازهٔ کاری از شیفت خودش می‌آید و طول هر نوبت از سرویس‌هایش، + * پس ردیف‌ها «نوبت‌های رزروشده + بازه‌های خالیِ بینشان»اند. کامپوننت تایم‌لاین یکی + * است تا کارت، وضعیت و عملیاتِ نوبت در هر دو نما یک چیز باشند. */ export default function ResourceDayPanel({ - resource, appointments, loading, canCreate, onBook, onView, + resource, date, appointments, loading, canCreate, queryKey, onBook, onView, }: { resource: ClinicResource; + /** روزِ نمایش، ISO `Y-m-d`. */ + date: string; appointments: Appointment[]; loading: boolean; canCreate: boolean; - onBook: () => void; + queryKey: unknown[]; + onBook: (slot: TimelineSlot | null) => void; onView: (appointment: Appointment) => void; }) { - const rows = [...appointments].sort((a, b) => a.slot_start - b.slot_start); + const dayQuery = useQuery>({ + queryKey: ['resource-day-slots', resource.uuid, date], + queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/day-slots?date=${date}`), + }); + + const data = dayQuery.data?.data; + const windows = data?.windows ?? []; + + const slots = React.useMemo( + () => buildServiceTimeline(windows, appointments), + [windows, appointments], + ); + + const workingRange = windows.length > 0 + ? { start: windows[0].start_time, end: windows[windows.length - 1].end_time } + : null; return (
@@ -38,62 +57,23 @@ export default function ResourceDayPanel({ background: 'var(--surface-2)', border: '1px solid var(--border)', fontSize: 12.5, color: 'var(--text-2)', }}> - - نوبت‌دهی این منبع سرویسی است — زمان از مدت سرویس و آزادبودن خودِ منبع می‌آید، نه از اسلات ثابت. - - {resource.supervisor && ( - - زیر نظر {resource.supervisor.name} + نوبت‌دهی سرویسی — نوبت‌ها بر اساس مدت سرویس چیده می‌شوند. + {workingRange && ( + + ساعت کاری: {workingRange.start} - {workingRange.end} )}
- {canCreate && ( - - )} - - {loading ? ( -
- ) : rows.length === 0 ? ( -

- برای «{resource.name}» در این روز نوبتی ثبت نشده است. -

- ) : ( -
- {rows.map((a) => ( - - ))} -
- )} - - {rows.length > 0 && ( -

{formatNumber(rows.length)} نوبت روی این منبع در این روز

- )} + s.appointment !== null)} + loading={loading || dayQuery.isLoading} + queryKey={queryKey} + onView={onView} + onBook={onBook} + emptyReason={data?.empty_reason ?? null} + errorMessage={dayQuery.isError ? ((dayQuery.error as Error)?.message || 'خطای نامشخص') : null} + />
); } diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx index 37da4659..1fbd5e5c 100644 --- a/assets/admin/components/appointments/ServiceSlotPicker.tsx +++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx @@ -18,10 +18,17 @@ export interface ServicePick { serviceUuids: string[]; durations: Record void; @@ -59,8 +66,8 @@ export default function ServiceSlotPicker({ useEffect(() => { if (!mounted.current) { mounted.current = true; return; } setSelected([]); setSectionUuid(''); - }, [doctorUuid]); - useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]); + }, [doctorUuid, resourceUuid]); + useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]); const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]); const durations = useMemo( @@ -71,16 +78,19 @@ export default function ServiceSlotPicker({ useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]); const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join(''); + const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join(''); const slotsQ = useQuery>({ - queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid], + queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid], queryFn: () => api.get( - `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1` - + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') - + durationsQs - + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') - + (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''), + resourceUuid + ? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs + : `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1` + + servicesQs + + durationsQs + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + + (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''), ), - enabled: !!doctorUuid && !!date && serviceUuids.length > 0, + enabled: (!!resourceUuid || !!doctorUuid) && !!date && serviceUuids.length > 0, }); const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? []; const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined; @@ -98,7 +108,9 @@ export default function ServiceSlotPicker({ if (services.length === 0) { return (
- سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است. + {resourceUuid + ? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید.' + : 'سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است.'}
); } diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index 2a780bf6..3834527a 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -187,6 +187,13 @@ const EMPTY_REASON_TEXT: Record = { holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' }, day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' }, outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' }, + // دلایلِ تقویمِ منبع — `resource/{uuid}/day-slots`. + no_shift: { title: 'این روز شیفت کاری ندارد', hint: 'در تقویم این منبع، برای این روز شیفتی تعریف نشده است' }, + national_holiday: { title: 'تعطیل رسمی', hint: 'این روز در تقویم رسمی تعطیل است' }, + tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' }, + exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' }, + resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبت‌دهی، منبع را از صفحهٔ «منابع» فعال کنید' }, + address_inactive: { title: 'شعبهٔ این منبع غیرفعال است', hint: 'تا وقتی شعبه غیرفعال باشد، منابعش نوبت نمی‌گیرند' }, }; export default function TurnsTimeline({ diff --git a/assets/admin/components/appointments/serviceTimeline.test.ts b/assets/admin/components/appointments/serviceTimeline.test.ts new file mode 100644 index 00000000..6101656b --- /dev/null +++ b/assets/admin/components/appointments/serviceTimeline.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { buildServiceTimeline } from './serviceTimeline'; +import type { Appointment } from '../../types'; + +const DAY = 1_800_000_000; // نیمه‌شبِ فرضی +const at = (h: number) => DAY + h * 3600; + +function appointment(from: number, to: number, over: Partial = {}): Appointment { + return { + uuid: `a-${from}`, patient_name: 'بیمار', patient_mobile: '09120000000', + doctor_uuid: 'd1', doctor_name: 'دکتر', slot_start: from, slot_end: to, + appointment_date: '', appointment_time: '', end_time: '', + status: 'confirmed', version: 1, created_at: '', + ...over, + } as Appointment; +} + +describe('buildServiceTimeline', () => { + const window = [{ start: at(8), end: at(14) }]; + + it('یک نوبت، بازهٔ کاری را به «خالی — نوبت — خالی» می‌شکند', () => { + const rows = buildServiceTimeline(window, [appointment(at(10), at(11))], at(0)); + + expect(rows.map(r => [r.start, r.end, r.appointment !== null])).toEqual([ + [at(8), at(10), false], + [at(10), at(11), true], + [at(11), at(14), false], + ]); + }); + + it('بدون نوبت، کل بازهٔ کاری یک ردیفِ خالی است', () => { + const rows = buildServiceTimeline(window, [], at(0)); + + expect(rows).toHaveLength(1); + expect(rows[0].is_available).toBe(true); + }); + + it('نوبت لغوشده جای خالی را نمی‌گیرد', () => { + const rows = buildServiceTimeline( + window, + [appointment(at(10), at(11), { status: 'cancelled_by_doctor' })], + at(0), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].appointment).toBeNull(); + }); + + it('نوبتِ رزرو (روزانه) بازه‌ای اشغال نمی‌کند', () => { + const rows = buildServiceTimeline(window, [appointment(at(10), at(11), { is_reserve: true })], at(0)); + + expect(rows).toHaveLength(1); + expect(rows[0].appointment).toBeNull(); + }); + + it('بازهٔ خالیِ گذشته به «اکنون» بریده می‌شود و ردیفِ تمام‌گذشته حذف', () => { + const rows = buildServiceTimeline([{ start: at(8), end: at(9) }, { start: at(10), end: at(14) }], [], at(11)); + + expect(rows).toHaveLength(1); + expect(rows[0].start).toBe(at(11)); + expect(rows[0].end).toBe(at(14)); + }); + + it('نوبتِ خارج از بازهٔ کاری، ردیف نمی‌سازد', () => { + const rows = buildServiceTimeline(window, [appointment(at(20), at(21))], at(0)); + + expect(rows.every(r => r.appointment === null)).toBe(true); + }); +}); diff --git a/assets/admin/components/appointments/serviceTimeline.ts b/assets/admin/components/appointments/serviceTimeline.ts new file mode 100644 index 00000000..e3a7a99e --- /dev/null +++ b/assets/admin/components/appointments/serviceTimeline.ts @@ -0,0 +1,67 @@ +import { formatTime } from '../../lib/utils'; +import { CANCELLED_STATUSES } from './turnStatus'; +import type { Appointment } from '../../types'; +import type { TimelineSlot } from './types'; + +/** یک بازهٔ کاری: شیفتِ منبع یا سشنِ برنامهٔ هفتگیِ پزشک. */ +export interface WorkingWindow { + start: number; + end: number; +} + +/** + * تایم‌لاینِ نوبت‌دهی **سرویسی**: نوبت‌های رزروشده + بازه‌های خالیِ بینشان. + * + * حالت سرویسی اسلاتِ ثابت ندارد — طول هر نوبت از سرویس‌هایش می‌آید — پس ردیف‌ها از + * روی بازهٔ کاری و نوبت‌های واقعی ساخته می‌شوند، نه از شبکهٔ اسلات. + * + * پزشک و منبع همین یک الگوریتم را دارند: بازهٔ کاری یکی از برنامهٔ هفتگی می‌آید و + * دیگری از تقویم منبع، ولی چیدنِ کارت‌ها فرقی نمی‌کند و دو نسخه‌اش یعنی دو رفتار. + */ +export function buildServiceTimeline( + windows: WorkingWindow[], + appointments: Appointment[], + now: number = Math.floor(Date.now() / 1000), +): TimelineSlot[] { + const booked = appointments + .filter(a => !CANCELLED_STATUSES.has(a.status) && !a.is_reserve) + .map(a => ({ + appointment: a, + start: Number(a.slot_start), + end: Number(a.slot_end), + })) + .sort((a, b) => a.start - b.start); + + const out: TimelineSlot[] = []; + + /** بازهٔ خالی؛ تکهٔ گذشته‌اش بریده می‌شود چون قابل رزرو نیست. */ + const pushFree = (start: number, end: number) => { + const from = start < now ? now : start; + if (end <= from) return; + out.push({ + start: from, end, + start_time: formatTime(from), end_time: formatTime(end), + is_available: true, appointment: null, cancelled_appointment: null, + }); + }; + + windows.forEach(({ start: winStart, end: winEnd }) => { + let cursor = winStart; + + booked + .filter(b => b.start >= winStart && b.start < winEnd) + .forEach(({ appointment, start, end }) => { + if (start > cursor) pushFree(cursor, start); + out.push({ + start, end, + start_time: formatTime(start), end_time: formatTime(end), + is_available: false, appointment, cancelled_appointment: null, + }); + cursor = Math.max(cursor, end); + }); + + if (cursor < winEnd) pushFree(cursor, winEnd); + }); + + return out; +} diff --git a/assets/admin/hooks/useResourceBookingServices.ts b/assets/admin/hooks/useResourceBookingServices.ts new file mode 100644 index 00000000..a901a6fb --- /dev/null +++ b/assets/admin/hooks/useResourceBookingServices.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { BookingService } from './useDoctorBookingServices'; +import type { ResourceServiceOffering } from '../types'; + +/** + * سرویس‌های قابلِ رزروِ یک منبع، به همان شکلِ سرویس‌های پزشک. + * + * نگاشت عمدی است: فرمِ انتخاب سرویس (`ServiceSlotPicker`) یک قرارداد دارد — بخش، + * نام، مدت — و ساختنِ نسخهٔ دومش برای منبع یعنی دو فرم که با هم درمی‌روند. + * + * مدتِ مؤثر مبناست نه `duration_minutes` خام: خالی‌بودنِ ستون یعنی «ارث از سطح + * بالاتر»، و نمایشش به‌صورت صفر همان دستگاه را بی‌مدت نشان می‌داد. + */ +export function useResourceBookingServices(resourceUuid: string | null | undefined) { + const q = useQuery>({ + queryKey: ['resource-services', resourceUuid], + queryFn: () => api.get(`/api/v1/resource/${resourceUuid}/services`), + enabled: !!resourceUuid, + }); + + const services: BookingService[] = useMemo( + () => (q.data?.data ?? []) + .filter(o => o.active && o.service_section) + .map(o => ({ + uuid: o.service_uuid, + name: o.service_name, + duration_minutes: o.effective_duration_minutes, + price_rials: o.effective_price_rials, + service_section: o.service_section!, + })), + [q.data], + ); + + return { services, isLoading: q.isLoading }; +} diff --git a/assets/admin/pages/AppointmentBookingModal.test.tsx b/assets/admin/pages/AppointmentBookingModal.test.tsx index bd68f090..08d43023 100644 --- a/assets/admin/pages/AppointmentBookingModal.test.tsx +++ b/assets/admin/pages/AppointmentBookingModal.test.tsx @@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({ import { api } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; -import { NewAppointmentModal } from './AppointmentsPage'; +import NewAppointmentModal from '../components/appointments/NewAppointmentModal'; const get = api.get as ReturnType; const post = api.post as ReturnType; diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx index 04d55cde..4d0900f9 100644 --- a/assets/admin/pages/AppointmentsPage.test.tsx +++ b/assets/admin/pages/AppointmentsPage.test.tsx @@ -72,6 +72,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () } 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 } }); + // بازهٔ کاری منبع از تقویم خودش می‌آید، نه از برنامهٔ هفتگی پزشک. + if (url.includes('/day-slots')) { + const day = Math.floor(Date.now() / 1000) + 7 * 86400; + return Promise.resolve({ success: true, data: { + windows: [{ start: day, end: day + 4 * 3600, start_time: '08:00', end_time: '12:00' }], + empty_reason: null, + } }); + } // منابع زیر نظر پزشک‌اند؛ تب هر منبع فقط زیر ناظرِ خودش دیده می‌شود. if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [ { uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } }, @@ -147,25 +155,32 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () await screen.findByText('دکتر محمدی'); await user.click(await screen.findByRole('button', { name: 'لیزر CO2' })); - expect(await screen.findByText(/نوبت‌دهی این منبع سرویسی است/)).toBeInTheDocument(); + expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'نوبت‌های خود پزشک' })); - expect(screen.queryByText(/نوبت‌دهی این منبع سرویسی است/)).toBeNull(); + expect(screen.queryByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeNull(); }); - /** باگ گزارش‌شده: نمای منبع نباید اسلاتی باشد. */ - it('نمای منبع سرویسی است و تایم‌لاین اسلاتی ندارد', async () => { + /** + * نمای منبع همان تایم‌لاینِ سرویسی است: بازهٔ کاری از `day-slots` خودِ منبع می‌آید + * و ردیف‌های خالی «افزودن نوبت سریع»اند — نه شبکهٔ اسلاتِ پزشک. + */ + it('نمای منبع، تایم‌لاین سرویسیِ تقویم خودِ منبع است', async () => { const user = userEvent.setup(); renderWithProviders(); await screen.findByText('دکتر محمدی'); await user.click(await screen.findByRole('button', { name: 'لیزر CO2' })); - expect(await screen.findByText(/نوبت‌دهی این منبع سرویسی است/)).toBeInTheDocument(); - expect(screen.getByText('افزودن نوبت سرویس')).toBeInTheDocument(); - // ورودی اسلاتی نباید باشد. - expect(screen.queryByText('افزودن نوبت سریع')).toBeNull(); + expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument(); + expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument(); + expect(screen.getByText(/ساعت کاری/)).toBeInTheDocument(); + // بازهٔ کاری از تقویم منبع خوانده می‌شود، نه از اسلات‌های پزشک. + const askedResourceDay = get.mock.calls.some( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'), + ); + expect(askedResourceDay).toBe(true); expect(screen.queryByText('این روز تعطیل است')).toBeNull(); }); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index dab274b8..04ec95cd 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -1,17 +1,14 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon, - AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, + AdjustmentsHorizontalIcon, } from '@heroicons/react/24/outline'; -import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment, ClinicResource } from '../types'; -import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils'; -import PriceInput from '../components/ui/PriceInput'; -import Modal from '../components/ui/Modal'; +import { formatDate, toGregorianDate, todayIso, formatTime } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import { useClinicContext } from '../hooks/useClinicContext'; import Pagination from '../components/ui/Pagination'; @@ -25,14 +22,16 @@ import TurnsViewToggle from '../components/appointments/TurnsViewToggle'; import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; import DoctorTabs from '../components/appointments/DoctorTabs'; import { useResources } from '../hooks/useResources'; -import ResourceBookingModal from '../components/appointments/ResourceBookingModal'; import ResourceDayPanel from '../components/appointments/ResourceDayPanel'; +import { useResourceBookingServices } from '../hooks/useResourceBookingServices'; import { useUrlState } from '../hooks/useUrlState'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; import { usePermissions } from '../hooks/usePermissions'; -import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; +import NewAppointmentModal from '../components/appointments/NewAppointmentModal'; +import type { BookingSlot } from '../components/appointments/NewAppointmentModal'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; +import { buildServiceTimeline } from '../components/appointments/serviceTimeline'; import { CANCELLED_STATUSES } from '../components/appointments/turnStatus'; import type { TimelineSlot } from '../components/appointments/types'; @@ -99,388 +98,6 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) ); } -// ───────────────────────────────────────────────────────────────────────────── -// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی) -// ───────────────────────────────────────────────────────────────────────────── - -interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; } - -interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } - -export function NewAppointmentModal({ - slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, -}: { - slot: BookingSlot; - onClose: () => void; - onSuccess: () => void; - serviceMode?: boolean; - services?: import('../hooks/useDoctorBookingServices').BookingService[]; - date?: string; - /** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */ - clinicUuid?: string | null; -}) { - const [mobile, setMobile] = useState(''); - const [lookup, setLookup] = useState(null); - const [patientName, setPatientName] = useState(''); - const [nationalCode, setNationalCode] = useState(''); - // معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل. - const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national'); - const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null }); - - const role = useAuthStore(s => s.primaryRole); - const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; - - // هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان، - // API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. - // - // قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛ - // منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن - // پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود. - type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } }; - const doctorPricingQ = useQuery({ - queryKey: ['insurance-pricing', slot.doctor_uuid], - queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`), - retry: false, - }); - const selfPricingQ = useQuery({ - queryKey: ['insurance-pricing'], - queryFn: () => api.get('/api/v1/insurance-pricing'), - enabled: doctorPricingQ.isError, - }); - const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any; - const freeVisit = pricing?.data?.free_visit_price_rials ?? 0; - const requireVisit = pricing?.data?.require_visit_price ?? false; - const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading; - const [visitPriceToman, setVisitPriceToman] = useState(0); - const [visitPriceTouched, setVisitPriceTouched] = useState(false); - useEffect(() => { - if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); - }, [freeVisit, visitPriceTouched]); - // هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است. - const [visitPriceOpen, setVisitPriceOpen] = useState(false); - const visitPriceExpanded = requireVisit || visitPriceOpen; - - const mobileValid = /^09\d{9}$/.test(mobile); - const nationalCodeValid = /^\d{10}$/.test(nationalCode); - // اعتبار کلید جستجو بسته به معیار انتخاب‌شده. - const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid; - const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot); - // یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است. - const foundWithNationalCode = !!lookup?.found && !!lookup.national_code; - const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی - - const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim(); - const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode; - const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10; - const visitPriceValid = !requireVisit || visitPriceToman > 0; - const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid; - - const search = useMutation({ - mutationFn: () => { - const q = searchBy === 'mobile' - ? `mobile=${encodeURIComponent(mobile)}` - : `national_code=${encodeURIComponent(nationalCode)}`; - return api.get(`/api/v1/my/appointment/patient-lookup?${q}`); - }, - onSuccess: (res: any) => { - const data: PatientLookup = res?.data ?? { found: false }; - setLookup(data); - setPatientName(data.found ? (data.name ?? '') : ''); - // موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند. - if (data.found) { - if (data.mobile) setMobile(data.mobile); - setNationalCode(data.national_code ?? ''); - } else if (searchBy === 'mobile') { - setNationalCode(''); - } - }, - onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'), - }); - - const mutation = useMutation({ - mutationFn: () => api.post(createEndpoint, { - doctor_uuid: slot.doctor_uuid, - slot_start: serviceMode ? pick.slot!.start : slot.start, - slot_end: serviceMode ? pick.slot!.end : slot.end, - patient_mobile: mobile, - patient_name: effectiveName, - patient_national_code: effectiveNationalCode, - ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), - ...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}), - ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), - }), - onSuccess: () => { - toast.success('نوبت با موفقیت ثبت شد'); - onSuccess(); - onClose(); - }, - onError: (e: any) => { - const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت'; - toast.error(msg); - }, - }); - - // تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. - function invalidateLookup() { - if (lookup !== null) { setLookup(null); setPatientName(''); } - } - function onMobileChange(v: string) { - // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). - setMobile(sanitizeMobileInput(v)); - if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); } - } - function onNationalSearchChange(v: string) { - setNationalCode(digitsOnly(v, 10)); - invalidateLookup(); - } - // جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند. - function onSwitchSearchBy(mode: 'mobile' | 'national') { - setSearchBy(mode); - setLookup(null); setPatientName(''); - setMobile(''); setNationalCode(''); - } - - const priceHint = pricingLoading - ? 'در حال خواندن تعرفهٔ پزشک…' - : freeVisit > 0 - ? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید` - : 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید'; - - return ( - - - - - } - > - {/* اسلات انتخاب‌شده */} -
- - - {serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`} - - {!serviceMode && ( - - {slot.doctor_name} - - )} -
- - {serviceMode && date && ( -
- -
- )} - -
- - {/* انتخاب معیار جستجو: موبایل یا کد ملی */} -
- {(['national', 'mobile'] as const).map(mode => ( - - ))} -
-
-
- {searchBy === 'mobile' ? ( - onMobileChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} - placeholder="مثال: 09123456789" - style={{ direction: 'ltr' }} - autoFocus - /> - ) : ( - onNationalSearchChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} - placeholder="کد ملی ۱۰ رقمی" - style={{ direction: 'ltr' }} - autoFocus - /> - )} -
- -
-
- - {foundWithNationalCode && ( -
- -
-
بیمار یافت شد
-
{lookup?.name}
-
کد ملی: {lookup?.national_code}
-
-
- )} - - {needsDetails && ( - <> -
- {lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'} -
-
- -
- setPatientName(e.target.value)} - placeholder="مثال: علی محمدی" - /> -
-
- {/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */} - {searchBy === 'national' && ( -
- -
- setMobile(sanitizeMobileInput(e.target.value))} - placeholder="مثال: 09123456789" - style={{ direction: 'ltr' }} - /> -
-
- )} - {/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */} - {searchBy === 'mobile' && ( -
- -
- setNationalCode(digitsOnly(e.target.value, 10))} - placeholder="کد ملی ۱۰ رقمی" - style={{ direction: 'ltr' }} - /> -
-
- )} - - )} - -
- {requireVisit ? ( - - ) : ( - // سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است). - - )} - {visitPriceExpanded && ( - <> -
- { setVisitPriceToman(v); setVisitPriceTouched(true); }} - suffix="تومان" - /> -
- {requireVisit && visitPriceToman <= 0 - ? هزینه ویزیت الزامی است - : {priceHint}} - {freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && ( - - )} - - )} -
-
- ); -} - // ───────────────────────────────────────────────────────────────────────────── // Main Page — «نوبت‌ ها» (طرح tauri) // ───────────────────────────────────────────────────────────────────────────── @@ -538,6 +155,9 @@ export default function AppointmentsPage() { const [bookingResource, setBookingResource] = useState(null); const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null; + // سرویس‌های همان منبع، نه کل کاتالوگ: منبعی که سرویسی را نمی‌دهد نباید در فهرست + // بیاید — سرور هم همان را با ۴۲۲ رد می‌کند. + const { services: resourceServices } = useResourceBookingServices(bookingResource?.uuid); /** * منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک @@ -709,43 +329,19 @@ export default function AppointmentsPage() { // حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبت‌های رزروشده // نمایش داده می‌شوند و باقیِ زمان به‌صورت بازه‌(های) خالیِ قابل‌رزرو بین آن‌ها. + // همان الگوریتمِ تایم‌لاینِ منبع؛ فقط بازهٔ کاری از برنامهٔ هفتگی می‌آید. if (serviceMode) { - const fmt = (ts: number) => formatTime(ts); const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); }; const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; - const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start)); - const now = Math.floor(Date.now() / 1000); - const out: TimelineSlot[] = []; - // بازهٔ خالیِ قابل‌رزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده می‌شود و بازه‌های - // کاملاً گذشته حذف می‌شوند تا فقط زمان قابل‌رزرو باقی بماند. - const pushFree = (start: number, end: number) => { - const s = start < now ? now : start; - if (end <= s) return; - out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null }); - }; - - rawSessions.forEach((session: any) => { + const windows = rawSessions.flatMap((session: any) => { const slots = (session.slots as any[]) ?? []; - if (!slots.length) return; + if (!slots.length) return []; const dayStart = Number(slots[0].start) - parseHM(session.start_time); - const winStart = dayStart + parseHM(session.start_time); - const winEnd = dayStart + parseHM(session.end_time); - - const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd); - let cursor = winStart; - inWin.forEach(a => { - const s = Number(a.slot_start), e = Number(a.slot_end); - if (s > cursor) pushFree(cursor, s); - out.push({ - start: s, end: e, start_time: fmt(s), end_time: fmt(e), - is_available: false, appointment: a, cancelled_appointment: null, - }); - cursor = Math.max(cursor, e); - }); - if (cursor < winEnd) pushFree(cursor, winEnd); + return [{ start: dayStart + parseHM(session.start_time), end: dayStart + parseHM(session.end_time) }]; }); - return out; + + return buildServiceTimeline(windows, appointments); } const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; @@ -920,13 +516,15 @@ export default function AppointmentsPage() { )} ) : activeResource ? ( - /* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاین اسلاتیِ پزشک اینجا - اصلاً رندر نمی‌شود — فهرست نوبت‌های همین منبع و یک ورودیِ سرویسی. */ + /* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاینش هم سرویسی ساخته + می‌شود — نوبت‌های همین منبع و بازه‌های خالیِ بینشان. */ setBookingResource(activeResource)} onView={openDetail} /> @@ -967,11 +565,26 @@ export default function AppointmentsPage() { + {/* ثبت نوبتِ منبع — همان مودالِ نوبت‌دهی سرویسی، با تقویمِ خودِ منبع */} {bookingResource && ( - setBookingResource(null)} - onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })} + onSuccess={() => { + qc.invalidateQueries({ queryKey: apptQueryKey }); + qc.invalidateQueries({ queryKey: ['resource-day-slots', bookingResource.uuid, selectedDate] }); + qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); + }} /> )} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index b588ebfc..1e26fec0 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1243,6 +1243,8 @@ export interface ResourceServiceOffering { effective_price_rials: number; duration_source: string | null; price_source: string; + /** بخشِ سرویس — مودالِ رزرو منبع سرویس‌ها را زیر بخششان گروه می‌کند. */ + service_section?: { uuid: string; name: string }; } /** گزینهٔ انتخاب سرویس در مودال منبع. */ diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 9814e3f2..63863d52 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -1118,6 +1118,39 @@ New query param `reserve=1` → returns only reserve-list entries; without it on منبع تحت `TenantFilter` است: `resource_uuid`ِ محیط دیگر هیچ ردیفی برنمی‌گرداند (۲۰۰ با فهرست خالی، نه ۴۰۳). +### ثبت نوبت برای یک منبع — `resource_uuid` روی `POST /api/v1/my/appointment` (2026-08) + +نوبت‌دهی منبع **سرویسی** است: مودالِ منبع همان فرمِ نوبت‌دهی سرویسیِ پزشک است و همین +اندپوینت را صدا می‌زند، فقط با `resource_uuid`. + +| فیلد | نوع | توضیح | +|---|---|---| +| `resource_uuid` | string | منبعِ نوبت. غیرفعال یا ناموجود ⇒ `422` | + +قواعدی که فقط وقتی این فیلد بیاید اعمال می‌شوند: + +- **پزشک از ناظرِ منبع می‌آید.** `doctor_uuid` اختیاری می‌شود؛ منبعِ بی‌ناظر ⇒ `422` + (رابطهٔ پزشک↔منبع یک جا تعریف شده است و پرسیدن دوباره‌اش یعنی دو منبعِ حقیقت). +- **مدت از زنجیرهٔ حلِ همان منبع** (`ResourceServiceResolver`) می‌آید نه از + `duration_minutes` خامِ سرویس: همان «RF فرکشنال» روی یک دستگاه ۵۰ دقیقه است و روی + دیگری ۴۰. `service_durations` همچنان همین نوبت را جابه‌جا می‌کند. +- **گیتِ سرویس، `ResourceServiceOffering` فعال است** نه پرچم `bookable`: سرویسی که + این منبع ارائه نمی‌دهد ⇒ `422` با فیلد `service_item_uuids`. +- **تداخل روی خودِ منبع جدا سنجیده می‌شود** ⇒ `409`. `bookAtomically` فقط اسلاتِ پزشک + را قفل می‌کند و دو پزشک می‌توانند یک دستگاه را هم‌زمان بگیرند. اشغال از دو جا خوانده + می‌شود: نوبت‌های `appointments.resource_id` و ردیف‌های `resource_occupancy` (رزرو + موقت، مسدودسازیِ دستی، نوبت‌های موتور منبع‌محور). ظرفیت منبع رعایت می‌شود: اتاق + دوتخته با یک نوبت پر نمی‌شود. +- **منبعِ محیط دیگر رد می‌شود** ⇒ `422`. منبع با uuid از بدنه می‌آید و `TenantFilter` + پوششش نمی‌دهد. + +> **محدودیت شناخته‌شده:** این مسیر ردیف `resource_occupancy` نمی‌سازد (مثل +> `POST /api/v1/appointment` عمومی که از قبل همین‌طور بود). پس نوبتِ پنلی برای موتور +> منبع‌محور (`appointment-availability`) نامرئی است؛ در جهت عکس — پنل هر دو منبعِ +> اشغال را می‌خواند — مشکلی نیست. + +تست: `tests/Appointment/ResourceAppointmentCreateTest.php`. + خروجی واقعی `GET /api/v1/my/appointments?limit=1&resource_uuid=ce070910-…`: ```json diff --git a/docs/api/resource.md b/docs/api/resource.md index 1247faf2..a58544a5 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -451,6 +451,58 @@ > تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای > هر منبع. +### `GET /api/v1/resource/{uuid}/day-slots` (2026-08) + +مجوز: `appointment_settings.view`. پارامتر: `date=Y-m-d` (الزامی). + +بازه‌های **کاری** منبع در یک روز — ورودیِ تایم‌لاینِ سرویسیِ صفحهٔ نوبت‌ها. معادلِ +`appointment-slots` پزشک، ولی از تقویم خودِ منبع: ساعت شعبه ∩ شیفت منبع − تعطیلات − +استثناها. + +نوبت‌ها اینجا **کسر نمی‌شوند**: تایم‌لاین نوبت‌های همان روز را جدا دارد و کارت‌ها را +داخل همین بازه‌ها می‌چیند؛ کسرشان یعنی نوبتِ ثبت‌شده جایی برای نشستن ندارد. + +```json +{ "success": true, "data": { + "resource_uuid": "ce07…", "date": "2026-08-04", "timezone": "Asia/Tehran", + "windows": [{ "start": 1785220200, "end": 1785249000, "start_time": "09:00", "end_time": "17:00" }], + "empty_reason": null +} } +``` + +`empty_reason` وقتی `windows` خالی است می‌گوید چرا: `no_shift`، `national_holiday`، +`tenant_holiday`، `exception`، `resource_inactive`، `address_inactive`. خالی‌بودن خطا +نیست و کلاینت نباید همه را «تعطیل» بنامد. + +### `GET /api/v1/resource/{uuid}/service-slots` (2026-08) + +مجوز: `appointment_settings.view`. + +| پارامتر | توضیح | +|---|---| +| `date` | `Y-m-d`، الزامی | +| `service_item_uuids[]` | یک یا چند سرویس؛ خالی ⇒ `422` | +| `durations[]` | override مدت، فقط برای همین محاسبه | + +زمان‌های خالیِ کافی برای مجموعِ مدتِ سرویس‌های انتخاب‌شده — معادلِ +`appointment-service-slots` پزشک. مدت هر سرویس از زنجیرهٔ حلِ همان منبع می‌آید +(`ResourceServiceResolver`)، اشغال از نوبت‌های `resource_id` **و** ردیف‌های +`resource_occupancy` خوانده می‌شود، و ظرفیت منبع رعایت می‌شود. زمان‌ها پشت‌سرهم +چیده می‌شوند (بدون بافر) و زمانِ گذشته پیشنهاد نمی‌شود. + +```json +{ "success": true, "data": { + "resource_uuid": "ce07…", "date": "2026-08-04", "total_duration_minutes": 60, + "start_times": [{ "start": 1785220200, "end": 1785223800, "start_time": "09:00", "end_time": "10:00" }] +} } +``` + +**۴۲۲:** سرویسِ ناموجود · سرویسی که این منبع ارائه نمی‌دهد (یا offeringش غیرفعال است) +· سرویسِ بی‌مدت · تاریخ بدفرم. **۴۰۴:** منبعِ محیط دیگر. + +ثبتِ خودِ نوبت با همین زمان‌ها از `POST /api/v1/my/appointment` با `resource_uuid` +انجام می‌شود ([`appointment.md`](appointment.md)). + ### `PUT /api/v1/resource/{uuid}/categories` مجوز: `appointment_settings.update`. @@ -581,9 +633,12 @@ idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ مت ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Resource # ۵۲ تست / ۱۲۹ assertion +ddev exec php bin/phpunit tests/Resource # ۱۳۱ تست / ۳۴۲ assertion ddev exec php vendor/bin/phpstan analyse src/Resource npx vitest run assets/admin/pages/ResourcesPage.test.tsx +npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \ + assets/admin/components/appointments/ResourceBookingModal.test.tsx \ + assets/admin/components/appointments/serviceTimeline.test.ts ``` --- diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 1a22a87b..8196b356 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -47,6 +47,8 @@ class MyAppointmentsController extends BaseController private readonly VisitPriceRequirementResolver $visitPriceResolver, private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo, + private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo, + private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots, ) {} /** @@ -121,6 +123,31 @@ class MyAppointmentsController extends BaseController $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); $isReserve = (bool) ($data['is_reserve'] ?? false); + /** + * نوبتِ یک منبع (دستگاه/اتاق/پرسنل) — همان فرمِ نوبت، با هدفِ منبع. + * + * پزشک از ناظرِ منبع استنتاج می‌شود: رابطهٔ پزشک↔منبع یک جا تعریف شده (فرم + * منبع) و پرسیدن دوباره‌اش در فرم نوبت یعنی دو منبعِ حقیقت. + */ + $resourceUuid = trim((string) ($data['resource_uuid'] ?? '')); + $resource = null; + + if ($resourceUuid !== '') { + $resource = $this->resourceRepo->findByUuid($resourceUuid); + + if ($resource === null || !$resource->isActive()) { + return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid'); + } + + if ($doctorUuid === '') { + $doctorUuid = $resource->getSupervisor()?->getUuid() ?? ''; + } + + if ($doctorUuid === '') { + return $this->error(ErrorCodes::VALIDATION, 'این منبع پزشک ناظر ندارد', 422, 'resource_uuid'); + } + } + // Reserve entries are day-level: only a date is picked in the UI, so // slot_end may equal slot_start and the past-slot rule does not apply. if ($isReserve && $slotEnd < $slotStart) { @@ -137,7 +164,16 @@ class MyAppointmentsController extends BaseController // مدتِ override منشی برای همین نوبت (پیش‌فرض سرویس تغییر نمی‌کند). { uuid: minutes } $durationOverrides = (array) ($data['service_durations'] ?? []); $serviceItems = []; - if (!empty($serviceUuids) && !$isReserve) { + if (!empty($serviceUuids) && !$isReserve && $resource !== null) { + // نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع می‌آید (هر دستگاه مدت خودش را + // دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` می‌نشیند. + ['minutes' => $resourceMinutes, 'items' => $serviceItems] = + $this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides); + + if ($computeDuration) { + $slotEnd = $slotStart + $resourceMinutes * 60; + } + } elseif (!empty($serviceUuids) && !$isReserve) { $totalMinutes = 0; foreach ($serviceUuids as $u) { $item = $this->itemRepo->findByUuid($u); @@ -231,6 +267,26 @@ class MyAppointmentsController extends BaseController return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids'); } + if ($resource !== null) { + /** + * منبع با uuid از بدنه می‌آید و `TenantFilter` پوششش نمی‌دهد؛ بدون این + * بررسی، دستگاهِ کلینیک دیگری روی نوبت این کلینیک می‌نشست. + */ + if (!$this->tenantOwnership->belongsTo($bookingContext, $resource)) { + return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid'); + } + + /** + * تداخل روی خودِ منبع جدا سنجیده می‌شود: `bookAtomically` فقط اسلاتِ پزشک + * را قفل می‌کند و دو پزشکِ متفاوت می‌توانند یک دستگاه را هم‌زمان بگیرند. + */ + if (!$isReserve && !$this->resourceSlots->isFree($resource, $slotStart, $slotEnd)) { + return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid'); + } + + $appointment->setResource($resource); + } + // پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem). foreach ($serviceItems as $si) { $appointment->addServiceItem($si); diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index e65d46fa..3cdaed0d 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -113,6 +113,46 @@ class AppointmentRepository extends ServiceEntityRepository return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId); } + /** + * بازه‌های اشغال‌شدهٔ یک **منبع** در پنجرهٔ [$from, $to). + * + * همان معیارِ نوبتِ پزشک، ولی محورش `resource_id` است: منبع می‌تواند نوبت‌هایی از + * چند پزشک داشته باشد، پس فیلترِ پزشک اینجا هم غلط است و هم ناقص. + * + * ردیف‌های `resource_occupancy` اینجا نمی‌آیند — آن‌ها مسیرِ موتور منبع‌محورند و + * {@see \App\Resource\Service\ResourceBookingSlotService} هر دو را کنار هم می‌گذارد. + * + * @return list مرتب‌شده بر اساس start + */ + public function findResourceBusyIntervals(int $resourceId, int $from, int $to, ?int $excludeId = null): array + { + $qb = $this->createQueryBuilder('a') + ->select('a.slotStart AS start, a.slotEnd AS end') + ->where('IDENTITY(a.resource) = :resource') + ->andWhere('a.slotStart < :to') + ->andWhere('a.slotEnd > :from') + ->andWhere('a.isReserve = false') + ->andWhere( + 'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))' + ) + ->setParameter('resource', $resourceId) + ->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES) + ->setParameter('pending', Appointment::STATUS_PENDING) + ->setParameter('now', time()) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('a.slotStart', 'ASC'); + + if ($excludeId !== null) { + $qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return array_map( + static fn (array $r): array => ['start' => (int) $r['start'], 'end' => (int) $r['end']], + $qb->getQuery()->getScalarResult(), + ); + } + /** * همان مجموعه‌ای که isSlotTaken() یک اسلات را با آن می‌سنجد — شامل نوبت‌های * رزروی. برای پیمایش چندروزه که نمی‌خواهد به ازای هر اسلات یک کوئری بزند. diff --git a/src/Resource/Controller/ResourceBookingSlotController.php b/src/Resource/Controller/ResourceBookingSlotController.php new file mode 100644 index 00000000..fb6aabf7 --- /dev/null +++ b/src/Resource/Controller/ResourceBookingSlotController.php @@ -0,0 +1,118 @@ +denyUnlessGranted($user, 'view'); + + $resource = $this->context->resource($user, $uuid); + $date = $this->requireDate($request); + + ['windows' => $windows, 'reason' => $reason] = $this->slots->workingWindows($resource, $date); + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'date' => $date, + 'timezone' => $resource->getAddress()->getTimezone(), + 'windows' => array_map( + static fn (TimeInterval $i): array => [ + 'start' => $i->start, + 'end' => $i->end, + 'start_time' => date('H:i', $i->start), + 'end_time' => date('H:i', $i->end), + ], + $windows, + ), + // خالی‌بودن دلایل مختلف دارد؛ کلاینت نباید همه را «تعطیل» بنامد. + 'empty_reason' => $reason, + ]); + } + + /** + * زمان‌های خالیِ کافی برای مجموعِ مدتِ سرویس‌های انتخاب‌شده روی این منبع. + * + * GET /api/v1/resource/{uuid}/service-slots?date=Y-m-d&service_item_uuids[]=..&durations[uuid]=دقیقه + */ + #[Route('/api/v1/resource/{uuid}/service-slots', name: 'resource_service_slots', methods: ['GET'])] + public function serviceSlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + $resource = $this->context->resource($user, $uuid); + $date = $this->requireDate($request); + + $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids')))); + + // وجود، ارائه‌شدن روی همین منبع و مدت — همه داخل resolveDuration و با + // AppException؛ ExceptionSubscriber همان envelope خطا را می‌سازد. + ['minutes' => $minutes] = $this->slots->resolveDuration( + $resource, + $uuids, + (array) $request->query->all('durations'), + ); + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'date' => $date, + 'total_duration_minutes' => $minutes, + 'start_times' => $this->slots->startTimes($resource, $date, $minutes), + ]); + } + + private function requireDate(Request $request): string + { + $date = trim((string) $request->query->get('date', '')); + + if ($date === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + throw new \App\Shared\Exception\AppException( + ErrorCodes::ERR_VALIDATION_001, + 'فرمت تاریخ نادرست است (Y-m-d)', + 422, + 'date', + ); + } + + return $date; + } +} diff --git a/src/Resource/Service/ResourceBookingSlotService.php b/src/Resource/Service/ResourceBookingSlotService.php new file mode 100644 index 00000000..bc56a50b --- /dev/null +++ b/src/Resource/Service/ResourceBookingSlotService.php @@ -0,0 +1,272 @@ +, reason: string|null, day_start: int} + */ + public function workingWindows(ClinicResource $resource, string $date): array + { + $dayStart = $this->dayStart($resource, $date); + $day = $this->availability->rawAvailability($resource, $dayStart, $dayStart)[0]; + + return [ + 'windows' => $day->intervals, + // اولین دلیل کافی است: پیام کاربر یک جمله است، نه فهرست. + 'reason' => $day->isEmpty() ? ($day->reasons[0] ?? 'no_shift') : null, + 'day_start' => $dayStart, + ]; + } + + /** + * بازه‌های آزادِ منبع در یک روز: بازهٔ کاری منهای اشغال، با احتساب ظرفیت. + * + * ظرفیت مهم است: اتاقِ سه‌تخته با یک نوبت پر نمی‌شود. دقیقه‌ای اشغال است که تعداد + * بازه‌های هم‌پوشانش به ظرفیت رسیده باشد — همان قاعدهٔ {@see ResourceFreeTimeCalculator}. + * + * @return list + */ + public function freeIntervals(ClinicResource $resource, string $date, ?int $excludeAppointmentId = null): array + { + ['windows' => $windows, 'day_start' => $dayStart] = $this->workingWindows($resource, $date); + + if ($windows === []) { + return []; + } + + $busy = $this->busyIntervals($resource, $dayStart, $dayStart + 86400, $excludeAppointmentId); + + return TimeInterval::subtractAll($windows, $this->fullRanges($busy, $resource->getCapacity())); + } + + /** + * زمان‌های شروعِ ممکن برای نوبتی به طول `$totalMinutes`. + * + * پشت‌سرهم چیده می‌شوند (بدون بافر): منبع بین دو بیمار برنامهٔ استراحت ندارد؛ اگر + * لازم باشد، «استثنای منبع» ابزارِ همان کار است. + * + * @return list + */ + public function startTimes( + ClinicResource $resource, + string $date, + int $totalMinutes, + ?int $excludeAppointmentId = null, + ): array { + if ($totalMinutes <= 0) { + return []; + } + + $length = $totalMinutes * 60; + $now = time(); + $out = []; + + foreach ($this->freeIntervals($resource, $date, $excludeAppointmentId) as $free) { + // زمانِ گذشته پیشنهاد نمی‌شود؛ ثبتش هم سرِ POST رد می‌شود. + for ($t = max($free->start, $now); $t + $length <= $free->end; $t += $length) { + $out[] = [ + 'start' => $t, + 'end' => $t + $length, + 'start_time' => date('H:i', $t), + 'end_time' => date('H:i', $t + $length), + ]; + } + } + + return $out; + } + + /** + * مجموع مدتِ سرویس‌های انتخاب‌شده روی این منبع. + * + * مدت از زنجیرهٔ حلِ منبع می‌آید ({@see ResourceServiceResolver})، نه از پیش‌فرضِ خامِ + * سرویس: همان دستگاه ممکن است «RF فرکشنال» را ۵۰ دقیقه بگیرد و دستگاه دیگر ۴۰. + * `$overrides` فقط همین نوبت را جابه‌جا می‌کند و پیش‌فرض را دست نمی‌زند. + * + * @param list $serviceUuids + * @param array $overrides uuid => دقیقه + * @return array{minutes: int, items: list} + * + * @throws AppException ۴۲۲ روی سرویسِ ناموجود، ارائه‌نشده یا بی‌مدت + */ + public function resolveDuration(ClinicResource $resource, array $serviceUuids, array $overrides = []): array + { + if ($serviceUuids === []) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); + } + + $minutes = 0; + $items = []; + + foreach ($serviceUuids as $uuid) { + $item = $this->items->findByUuid($uuid); + + if ($item === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + + $this->assertOffered($resource, $item); + + $override = isset($overrides[$uuid]) && (int) $overrides[$uuid] > 0 ? (int) $overrides[$uuid] : null; + $duration = $override ?? $this->resolver->resolve($resource, $item, $resource->getAddress())->durationMinutes; + + if ($duration === null || $duration <= 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + + $minutes += $duration; + $items[] = $item; + } + + return ['minutes' => $minutes, 'items' => $items]; + } + + /** آیا این منبع در بازهٔ [$start, $end) هنوز جا دارد؟ */ + public function isFree(ClinicResource $resource, int $start, int $end, ?int $excludeAppointmentId = null): bool + { + $busy = $this->busyIntervals($resource, $start, $end, $excludeAppointmentId); + + foreach ($this->fullRanges($busy, $resource->getCapacity()) as $full) { + if ($full->start < $end && $full->end > $start) { + return false; + } + } + + return true; + } + + /** + * سرویسی که این منبع ارائه نمی‌دهد، همین‌جا رد می‌شود. + * + * @throws AppException ۴۲۲ + */ + public function assertOffered(ClinicResource $resource, ServiceItem $item): void + { + $offering = $this->offerings->findOneFor($resource, $item); + + if ($offering === null || !$offering->isActive()) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'این منبع این سرویس را ارائه نمی‌دهد', + 422, + 'service_item_uuids', + ); + } + } + + /** + * هرچه منبع را می‌گیرد: نوبت‌های مستقیمِ همین منبع + ردیف‌های اشغالِ موتور + * منبع‌محور (رزرو موقت، مسدودسازی دستی، نوبت‌های چندبخشی). + * + * دو منبعِ داده‌اند چون دو مسیرِ رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد: مسیر + * پنل روی `appointments.resource_id` می‌نشیند و مسیر موتور روی `resource_occupancy`. + * شمردنِ فقط یکی، آن یکی را نامرئی می‌کرد. + * + * @return list + */ + private function busyIntervals(ClinicResource $resource, int $from, int $to, ?int $excludeAppointmentId): array + { + $rows = $this->appointments->findResourceBusyIntervals( + (int) $resource->getId(), + $from, + $to, + $excludeAppointmentId, + ); + + foreach ($this->occupancy->busyByResource([(int) $resource->getId()], $from, $to)[(int) $resource->getId()] ?? [] as $row) { + $rows[] = $row; + } + + return $rows; + } + + /** + * بازه‌هایی که ظرفیت در آن‌ها تمام است — جاروی خطی روی مرزها. + * + * @param list $busy + * @return list + */ + private function fullRanges(array $busy, int $capacity): array + { + if ($busy === []) { + return []; + } + + $capacity = max(1, $capacity); + $delta = []; + + foreach ($busy as $row) { + $delta[$row['start']] = ($delta[$row['start']] ?? 0) + 1; + $delta[$row['end']] = ($delta[$row['end']] ?? 0) - 1; + } + + ksort($delta); + + $points = array_keys($delta); + $open = 0; + $out = []; + + foreach ($points as $i => $point) { + $open += $delta[$point]; + $next = $points[$i + 1] ?? null; + + if ($next !== null && $open >= $capacity) { + $out[] = new TimeInterval($point, $next); + } + } + + return TimeInterval::mergeAll($out); + } + + /** نیمه‌شبِ روز، به وقتِ محلیِ شعبهٔ منبع. */ + private function dayStart(ClinicResource $resource, string $date): int + { + $timezone = new \DateTimeZone($resource->getAddress()->getTimezone()); + + $midnight = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' 00:00:00', $timezone); + + if ($midnight === false) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); + } + + return $midnight->getTimestamp(); + } +} diff --git a/tests/Appointment/ResourceAppointmentCreateTest.php b/tests/Appointment/ResourceAppointmentCreateTest.php new file mode 100644 index 00000000..7047974c --- /dev/null +++ b/tests/Appointment/ResourceAppointmentCreateTest.php @@ -0,0 +1,236 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر منبع'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $address = DoctorAddress::forDoctor($doctor); + $address->setName('مطب شخصی'); + $this->em->persist($address); + $this->em->flush(); + + $type = new ResourceType('doctor', (int) $doctor->getId(), 'device', 'دستگاه'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'لیزر CO2'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + $this->em->flush(); + + return [$user, $doctor, $resource, $address]; + } + + private function service(DoctorAddress $address, string $name, ?int $serviceMinutes): ServiceItem + { + $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); + $this->em->persist($section); + + $item = new ServiceItem($section, $name, 5_000_000); + $item->setBookable(true); + + if ($serviceMinutes !== null) { + $item->setDurationMinutes($serviceMinutes); + } + + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function offer(ClinicResource $resource, ServiceItem $item, ?int $minutes): void + { + $offering = new ResourceServiceOffering($resource, $item); + $offering->setDurationMinutes($minutes); + $this->em->persist($offering); + $this->em->flush(); + } + + /** @param array $extra */ + private function body(ClinicResource $resource, int $start, array $extra = []): array + { + return array_merge([ + 'resource_uuid' => $resource->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 600, + 'duration_from_services' => true, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار منبع', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + ], $extra); + } + + private function reload(string $uuid): Appointment + { + $this->em->clear(); + + return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); + } + + private function tomorrowAt(int $hour): int + { + return (int) strtotime('tomorrow midnight') + $hour * 3600; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testTheAppointmentLandsOnTheResourceWithTheResourceDuration(): void + { + [$user, $doctor, $resource, $address] = $this->doctorWithResource(); + // پیش‌فرض سرویس ۳۰ دقیقه است، ولی این دستگاه ۵۰ دقیقه می‌گیرد. + $service = $this->service($address, 'RF فرکشنال', 30); + $this->offer($resource, $service, 50); + + $start = $this->tomorrowAt(10); + $res = $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [ + 'service_item_uuids' => [$service->getUuid()], + ])); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + + $appointment = $this->reload($res['data']['uuid']); + self::assertSame($resource->getUuid(), $appointment->getResource()?->getUuid()); + self::assertSame($start + 50 * 60, $appointment->getSlotEnd()); + // پزشک فرستاده نشد؛ از ناظرِ منبع آمد. + self::assertSame($doctor->getUuid(), $appointment->getDoctor()->getUuid()); + } + + public function testSeveralServicesAddUpAndAPerRequestDurationWins(): void + { + [$user, , $resource, $address] = $this->doctorWithResource(); + $laser = $this->service($address, 'لیزر زیربغل', 20); + $cryo = $this->service($address, 'کرایوتراپی', 25); + $this->offer($resource, $laser, 20); + $this->offer($resource, $cryo, 25); + + $start = $this->tomorrowAt(11); + $res = $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [ + 'service_item_uuids' => [$laser->getUuid(), $cryo->getUuid()], + 'service_durations' => [$laser->getUuid() => 40], + ])); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame($start + 65 * 60, $this->reload($res['data']['uuid'])->getSlotEnd()); + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + /** ظرفیت ۲ یعنی نوبت دوم هنوز جا دارد. */ + public function testASecondBookingFitsWhileTheResourceStillHasCapacity(): void + { + [$user, , $resource, $address] = $this->doctorWithResource(); + $resource->setCapacity(2); + $this->em->flush(); + + $service = $this->service($address, 'اکسیژن‌تراپی', 60); + $this->offer($resource, $service, 60); + + $start = $this->tomorrowAt(9); + $this->em->persist(new ResourceOccupancy($resource, $start, $start + 3600)); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [ + 'service_item_uuids' => [$service->getUuid()], + ])); + + self::assertSame(201, $this->responseCode()); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + /** دستگاهی که موتور منبع‌محور گرفته، از پنل دوباره فروخته نمی‌شود. */ + public function testATimeAlreadyTakenOnTheResourceIsRefused(): void + { + [$user, , $resource, $address] = $this->doctorWithResource(); + $service = $this->service($address, 'مزوتراپی', 60); + $this->offer($resource, $service, 60); + + $start = $this->tomorrowAt(14); + $this->em->persist(new ResourceOccupancy($resource, $start, $start + 3600)); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [ + 'service_item_uuids' => [$service->getUuid()], + ])); + + self::assertSame(409, $this->responseCode()); + } + + public function testAServiceTheResourceDoesNotOfferIsRefused(): void + { + [$user, , $resource, $address] = $this->doctorWithResource(); + $service = $this->service($address, 'ویزیت عمومی', 15); // بدون offering + + $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $this->tomorrowAt(12), [ + 'service_item_uuids' => [$service->getUuid()], + ])); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnUnknownResourceIsRefused(): void + { + [$user, , , $address] = $this->doctorWithResource(); + $service = $this->service($address, 'لیزر پا', 20); + + $this->authJson('POST', '/api/v1/my/appointment', $user, [ + 'resource_uuid' => 'no-such-resource', + 'slot_start' => $this->tomorrowAt(13), + 'slot_end' => $this->tomorrowAt(13) + 1200, + 'duration_from_services' => true, + 'service_item_uuids' => [$service->getUuid()], + 'patient_mobile' => '09121234567', + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '0012345678', + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** منبعِ محیط دیگر روی نوبت این محیط نمی‌نشیند. */ + public function testAResourceOfAnotherEnvironmentIsRefused(): void + { + [$user, , , $address] = $this->doctorWithResource(); + [, , $foreignResource, $foreignAddress] = $this->doctorWithResource(); + + $service = $this->service($address, 'لیزر صورت', 30); + $foreign = $this->service($foreignAddress, 'لیزر صورت', 30); + $this->offer($foreignResource, $foreign, 30); + + $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($foreignResource, $this->tomorrowAt(15), [ + 'doctor_uuid' => $this->em->getRepository(Doctor::class)->findByUser($user)->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ])); + + self::assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Resource/ResourceBookingSlotTest.php b/tests/Resource/ResourceBookingSlotTest.php new file mode 100644 index 00000000..69b6175f --- /dev/null +++ b/tests/Resource/ResourceBookingSlotTest.php @@ -0,0 +1,282 @@ +doctorWithAddress(); + + $resource = new ClinicResource($address, $this->resourceType($address), 'لیزر CO2'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + $this->em->flush(); + + $this->em->persist(new ResourceCalendar( + $resource, + $this->dayOfWeek($this->midnight()), + self::SHIFT_FROM, + self::SHIFT_TO, + )); + $this->em->flush(); + + return [$user, $doctor, $resource, $address]; + } + + private function offer(ClinicResource $resource, DoctorAddress $address, string $name, ?int $minutes): ServiceItem + { + $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); + $this->em->persist($section); + + $item = new ServiceItem($section, $name, 5_000_000); + $item->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + $offering = new ResourceServiceOffering($resource, $item); + $offering->setDurationMinutes($minutes); + $this->em->persist($offering); + $this->em->flush(); + + return $item; + } + + /** فردا انتخاب می‌شود تا «زمانِ گذشته» نتیجه را کوتاه نکند. */ + private function midnight(): int + { + return (int) strtotime('tomorrow midnight'); + } + + private function date(): string + { + return date('Y-m-d', $this->midnight()); + } + + /** ۰ = شنبه، همان قرارداد تقویم منبع. */ + private function dayOfWeek(int $timestamp): int + { + return ((int) date('w', $timestamp) + 1) % 7; + } + + private function bookOnResource(Doctor $doctor, User $user, ClinicResource $resource, int $start, int $end): void + { + $appointment = new Appointment($doctor, $user, $start, $end); + $appointment->setResource($resource); + $appointment->assignTenant(EntityContext::forBooking($doctor, null)); + $this->em->persist($appointment); + $this->em->flush(); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testTheDayViewReturnsTheResourceShiftAsItsWorkingWindow(): void + { + [$user, , $resource] = $this->resourceWithShift(); + + $body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNull($body['data']['empty_reason']); + self::assertCount(1, $body['data']['windows']); + self::assertSame($this->midnight() + self::SHIFT_FROM * 60, $body['data']['windows'][0]['start']); + self::assertSame($this->midnight() + self::SHIFT_TO * 60, $body['data']['windows'][0]['end']); + } + + public function testServiceSlotsAreChainedFromTheResourceDurationOfEachService(): void + { + [$user, , $resource, $address] = $this->resourceWithShift(); + $laser = $this->offer($resource, $address, 'لیزر زیربغل', 25); + $rf = $this->offer($resource, $address, 'RF فرکشنال', 35); + + $body = $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $laser->getUuid(), + $rf->getUuid(), + ), $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(60, $body['data']['total_duration_minutes']); + // ۸ ساعت شیفت ÷ ۶۰ دقیقه = ۸ زمان، پشت‌سرهم از ۰۹:۰۰. + self::assertCount(8, $body['data']['start_times']); + self::assertSame('09:00', $body['data']['start_times'][0]['start_time']); + self::assertSame('10:00', $body['data']['start_times'][1]['start_time']); + } + + public function testAnAppointmentOnTheResourceRemovesItsTimeFromTheOffer(): void + { + [$user, $doctor, $resource, $address] = $this->resourceWithShift(); + $service = $this->offer($resource, $address, 'لیزر صورت', 60); + $occupied = $this->midnight() + 10 * 3600; + + $this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600); + + $body = $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $service->getUuid(), + ), $user); + + $starts = array_column($body['data']['start_times'], 'start_time'); + self::assertNotContains('10:00', $starts); + self::assertContains('09:00', $starts); + self::assertContains('11:00', $starts); + } + + /** مدتِ فرستاده‌شده فقط همین محاسبه را جابه‌جا می‌کند، نه پیش‌فرضِ سرویس را. */ + public function testAPerRequestDurationOverridesTheOffering(): void + { + [$user, , $resource, $address] = $this->resourceWithShift(); + $service = $this->offer($resource, $address, 'کرایوتراپی', 25); + + $body = $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&durations[%s]=120', + $resource->getUuid(), + $this->date(), + $service->getUuid(), + $service->getUuid(), + ), $user); + + self::assertSame(120, $body['data']['total_duration_minutes']); + self::assertCount(4, $body['data']['start_times']); // ۸ ساعت ÷ ۲ ساعت + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + /** اتاق دوتخته با یک نوبت پر نمی‌شود. */ + public function testCapacityKeepsTheTimeOnOfferUntilItIsFull(): void + { + [$user, $doctor, $resource, $address] = $this->resourceWithShift(); + $resource->setCapacity(2); + $this->em->flush(); + + $service = $this->offer($resource, $address, 'اکسیژن‌تراپی', 60); + $occupied = $this->midnight() + 10 * 3600; + $this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600); + + $body = $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $service->getUuid(), + ), $user); + + self::assertContains('10:00', array_column($body['data']['start_times'], 'start_time')); + } + + /** روزِ بی‌شیفت خطا نیست؛ باید بگوید چرا خالی است. */ + public function testADayWithoutAShiftExplainsItself(): void + { + [$user, , $resource] = $this->resourceWithShift(); + $otherDay = date('Y-m-d', $this->midnight() + 3 * 86400); + + $body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$otherDay}", $user); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['windows']); + self::assertNotNull($body['data']['empty_reason']); + } + + /** رزرو موقتِ موتور منبع‌محور هم وقت را می‌گیرد، نه فقط نوبتِ ثبت‌شده. */ + public function testAHoldFromTheResourceEngineAlsoBlocksTheTime(): void + { + [$user, , $resource, $address] = $this->resourceWithShift(); + $service = $this->offer($resource, $address, 'مزوتراپی', 60); + $held = $this->midnight() + 12 * 3600; + + $this->em->persist(new ResourceOccupancy($resource, $held, $held + 3600, ResourceOccupancy::STATUS_HOLD)); + $this->em->flush(); + + $body = $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $service->getUuid(), + ), $user); + + self::assertNotContains('12:00', array_column($body['data']['start_times'], 'start_time')); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAServiceThisResourceDoesNotOfferIsRefused(): void + { + [$user, , $resource, $address] = $this->resourceWithShift(); + + $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش دیگر'); + $this->em->persist($section); + $foreign = new ServiceItem($section, 'سرویس بی‌ربط', 1_000_000); + $foreign->setDurationMinutes(20)->setBookable(true); + $this->em->persist($foreign); + $this->em->flush(); + + $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $foreign->getUuid(), + ), $user); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnOfferingWithoutADurationIsRefused(): void + { + [$user, , $resource, $address] = $this->resourceWithShift(); + $service = $this->offer($resource, $address, 'بدون مدت', null); + + $this->authJson('GET', sprintf( + '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', + $resource->getUuid(), + $this->date(), + $service->getUuid(), + ), $user); + + self::assertSame(422, $this->responseCode()); + } + + public function testAMalformedDateIsRefused(): void + { + [$user, , $resource] = $this->resourceWithShift(); + + $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date=05-08-2026", $user); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnotherEnvironmentDoesNotSeeThisResource(): void + { + [, , $resource] = $this->resourceWithShift(); + [$stranger] = $this->doctorWithAddress(); + + $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $stranger); + + self::assertSame(404, $this->responseCode()); + } +}