feat: Implement resource booking functionality
- Add service timeline builder for appointments to manage available slots. - Create a hook to fetch resource booking services with effective durations. - Develop ResourceBookingSlotController to handle API requests for resource booking slots. - Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations. - Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
@@ -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<PatientLookup | null>(null);
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
||||
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
||||
const [pick, setPick] = useState<ServicePick>({ 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<Pricing>({
|
||||
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<Pricing>({
|
||||
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 (
|
||||
<Modal
|
||||
open
|
||||
title="ثبت نوبت"
|
||||
size="sm"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!isValid || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
}}>
|
||||
{resource
|
||||
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
||||
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
|
||||
{resource ? resource.name : pickerMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
|
||||
</span>
|
||||
{(resource || !pickerMode) && slot.doctor_name && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
||||
{slot.doctor_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pickerMode && date && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={slot.doctor_uuid}
|
||||
resourceUuid={resource?.uuid}
|
||||
date={date}
|
||||
services={services}
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
{(['national', 'mobile'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => onNationalSearchChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn soft"
|
||||
onClick={() => search.mutate()}
|
||||
disabled={!searchValid || search.isPending}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
||||
{search.isPending ? '...' : 'جستجو'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foundWithNationalCode && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--success-bg)', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
|
||||
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDetails && (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
||||
}}>
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={e => setPatientName(e.target.value)}
|
||||
placeholder="مثال: علی محمدی"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
||||
{searchBy === 'national' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
||||
{searchBy === 'mobile' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-block">
|
||||
{requireVisit ? (
|
||||
<label>
|
||||
هزینه ویزیت (تومان)<span className="req"> *</span>
|
||||
</label>
|
||||
) : (
|
||||
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisitPriceOpen(o => !o)}
|
||||
aria-expanded={visitPriceExpanded}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
||||
padding: 0, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)',
|
||||
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{visitPriceExpanded && (
|
||||
<>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<ResourceDayPanel
|
||||
resource={resource}
|
||||
date="2026-08-04"
|
||||
appointments={appointments}
|
||||
loading={false}
|
||||
canCreate
|
||||
queryKey={['appointments']}
|
||||
onBook={() => {}}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ApiResponse<DaySlotsData>>({
|
||||
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 (
|
||||
<div>
|
||||
@@ -38,62 +57,23 @@ export default function ResourceDayPanel({
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
fontSize: 12.5, color: 'var(--text-2)',
|
||||
}}>
|
||||
<span>
|
||||
نوبتدهی این منبع سرویسی است — زمان از مدت سرویس و آزادبودن خودِ منبع میآید، نه از اسلات ثابت.
|
||||
</span>
|
||||
{resource.supervisor && (
|
||||
<span style={{ color: 'var(--text-3)', flexShrink: 0 }}>
|
||||
زیر نظر {resource.supervisor.name}
|
||||
<span>نوبتدهی سرویسی — نوبتها بر اساس مدت سرویس چیده میشوند.</span>
|
||||
{workingRange && (
|
||||
<span dir="ltr" style={{ color: 'var(--text-3)', flexShrink: 0 }}>
|
||||
ساعت کاری: {workingRange.start} - {workingRange.end}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={onBook}
|
||||
style={{ marginBottom: 14 }}
|
||||
>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن نوبت سرویس
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="skeleton" style={{ height: 90, borderRadius: 'var(--r-sm)' }} />
|
||||
) : rows.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 2 }}>
|
||||
برای «{resource.name}» در این روز نوبتی ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{rows.map((a) => (
|
||||
<button
|
||||
key={a.uuid}
|
||||
type="button"
|
||||
onClick={() => onView(a)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'right',
|
||||
padding: '10px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span dir="ltr" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', flexShrink: 0 }}>
|
||||
{hhmm(a.slot_start)} – {hhmm(a.slot_end)}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{a.patient_name || '—'}
|
||||
{a.service_item?.name ? ` · ${a.service_item.name}` : ''}
|
||||
</span>
|
||||
<StatusBadge type="appointment" value={a.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<p className="field-hint">{formatNumber(rows.length)} نوبت روی این منبع در این روز</p>
|
||||
)}
|
||||
<TurnsTimeline
|
||||
slots={canCreate ? slots : slots.filter(s => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,17 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
|
||||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||||
*/
|
||||
export default function ServiceSlotPicker({
|
||||
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||
excludeAppointmentUuid, initialSelection,
|
||||
}: {
|
||||
doctorUuid: string;
|
||||
/**
|
||||
* نوبتدهی روی یک منبع: زمانها از تقویم خودِ منبع میآیند، نه از برنامهٔ پزشک.
|
||||
*
|
||||
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
|
||||
* منبعِ زمان عوض میشود نه کامپوننت — دو نسخه یعنی دو رفتار.
|
||||
*/
|
||||
resourceUuid?: string;
|
||||
date: string;
|
||||
services: BookingService[];
|
||||
onSelect: (v: ServicePick) => 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<ApiResponse<any>>({
|
||||
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 (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
{resourceUuid
|
||||
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.'
|
||||
: 'سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,13 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
|
||||
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({
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ApiResponse<ResourceServiceOffering[]>>({
|
||||
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 };
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -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(<AppointmentsPage />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<PatientLookup | null>(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<Pricing>({
|
||||
queryKey: ['insurance-pricing', slot.doctor_uuid],
|
||||
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
|
||||
retry: false,
|
||||
});
|
||||
const selfPricingQ = useQuery<Pricing>({
|
||||
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 (
|
||||
<Modal
|
||||
open
|
||||
title="ثبت نوبت"
|
||||
size="sm"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!isValid || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* اسلات انتخابشده */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
}}>
|
||||
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
|
||||
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
|
||||
</span>
|
||||
{!serviceMode && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
||||
{slot.doctor_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serviceMode && date && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={slot.doctor_uuid}
|
||||
date={date}
|
||||
services={services}
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
{(['national', 'mobile'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => onNationalSearchChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn soft"
|
||||
onClick={() => search.mutate()}
|
||||
disabled={!searchValid || search.isPending}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
||||
{search.isPending ? '...' : 'جستجو'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foundWithNationalCode && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--success-bg)', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
|
||||
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDetails && (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
||||
}}>
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={e => setPatientName(e.target.value)}
|
||||
placeholder="مثال: علی محمدی"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
||||
{searchBy === 'national' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
||||
{searchBy === 'mobile' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-block">
|
||||
{requireVisit ? (
|
||||
<label>
|
||||
هزینه ویزیت (تومان)<span className="req"> *</span>
|
||||
</label>
|
||||
) : (
|
||||
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisitPriceOpen(o => !o)}
|
||||
aria-expanded={visitPriceExpanded}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
||||
padding: 0, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)',
|
||||
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{visitPriceExpanded && (
|
||||
<>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Page — «نوبت ها» (طرح tauri)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -538,6 +155,9 @@ export default function AppointmentsPage() {
|
||||
|
||||
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(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 ? (
|
||||
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایملاین اسلاتیِ پزشک اینجا
|
||||
اصلاً رندر نمیشود — فهرست نوبتهای همین منبع و یک ورودیِ سرویسی. */
|
||||
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایملاینش هم سرویسی ساخته
|
||||
میشود — نوبتهای همین منبع و بازههای خالیِ بینشان. */
|
||||
<ResourceDayPanel
|
||||
resource={activeResource}
|
||||
date={selectedDate}
|
||||
appointments={filteredAppointments}
|
||||
loading={apptQuery.isLoading}
|
||||
canCreate={!isRepresentation && canCreateAppt}
|
||||
queryKey={apptQueryKey}
|
||||
onBook={() => setBookingResource(activeResource)}
|
||||
onView={openDetail}
|
||||
/>
|
||||
@@ -967,11 +565,26 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ثبت نوبتِ منبع — همان مودالِ نوبتدهی سرویسی، با تقویمِ خودِ منبع */}
|
||||
{bookingResource && (
|
||||
<ResourceBookingModal
|
||||
resource={bookingResource}
|
||||
<NewAppointmentModal
|
||||
slot={{
|
||||
start: 0, end: 0, start_time: '', end_time: '',
|
||||
// پزشکِ نوبت همان ناظرِ منبع است؛ اینجا فقط برای نمایش و تعرفهٔ ویزیت
|
||||
// میآید و سرور خودش هم از منبع استنتاجش میکند.
|
||||
doctor_uuid: bookingResource.supervisor?.uuid ?? '',
|
||||
doctor_name: bookingResource.supervisor?.name ?? '',
|
||||
}}
|
||||
resource={{ uuid: bookingResource.uuid, name: bookingResource.name }}
|
||||
services={resourceServices}
|
||||
date={selectedDate}
|
||||
clinicUuid={effectiveClinicUuid}
|
||||
onClose={() => 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] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1243,6 +1243,8 @@ export interface ResourceServiceOffering {
|
||||
effective_price_rials: number;
|
||||
duration_source: string | null;
|
||||
price_source: string;
|
||||
/** بخشِ سرویس — مودالِ رزرو منبع سرویسها را زیر بخششان گروه میکند. */
|
||||
service_section?: { uuid: string; name: string };
|
||||
}
|
||||
|
||||
/** گزینهٔ انتخاب سرویس در مودال منبع. */
|
||||
|
||||
@@ -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
|
||||
|
||||
+56
-1
@@ -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[<uuid>]` | 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<array{start: int, end: int}> مرتبشده بر اساس 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() یک اسلات را با آن میسنجد — شامل نوبتهای
|
||||
* رزروی. برای پیمایش چندروزه که نمیخواهد به ازای هر اسلات یک کوئری بزند.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Service\ResourceBookingSlotService;
|
||||
use App\Resource\Service\ResourceContext;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Time\TimeInterval;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* زمانهای نوبتدهیِ یک منبع — معادلِ `appointment-slots` و `appointment-service-slots`
|
||||
* پزشک، ولی از تقویم خودِ منبع.
|
||||
*
|
||||
* از {@see ResourceCalendarController} جدا است چون کارِ دیگری میکند: آنجا تقویم را
|
||||
* **میسازد** (شیفت، استثنا)، اینجا از روی همان تقویم وقتِ قابلِ رزرو را میدهد.
|
||||
*/
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ResourceBookingSlotController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceContext $context,
|
||||
private readonly ResourceBookingSlotService $slots,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* بازههای کاریِ منبع در یک روز — ورودیِ تایملاینِ صفحهٔ نوبتها.
|
||||
*
|
||||
* نوبتها اینجا کسر **نمیشوند**: تایملاین خودش نوبتهای همان روز را دارد و کارتها
|
||||
* را داخل همین بازهها میچیند؛ کسرشان یعنی نوبتِ ثبتشده جایی برای نشستن ندارد.
|
||||
*
|
||||
* GET /api/v1/resource/{uuid}/day-slots?date=Y-m-d
|
||||
*/
|
||||
#[Route('/api/v1/resource/{uuid}/day-slots', name: 'resource_day_slots', methods: ['GET'])]
|
||||
public function daySlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Service\ResourceServiceResolver;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ResourceServiceOfferingRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Time\TimeInterval;
|
||||
|
||||
/**
|
||||
* وقتِ **قابلِ رزروِ** یک منبع در یک روز — همان چیزی که `SlotCalculatorService` برای
|
||||
* پزشک میدهد، ولی از تقویم خودِ منبع.
|
||||
*
|
||||
* جدا از {@see ResourceAvailabilityService} است و آن را مصرف میکند: آنجا میگوید منبع
|
||||
* کِی **باز** است (ساعت شعبه ∩ شیفت − تعطیلات − استثناها)، اینجا از همان بازهها آنچه
|
||||
* را گرفته شده کم میکند و میگوید نوبت کجا جا میشود.
|
||||
*
|
||||
* منبع اسلاتِ ثابت ندارد: زمانها از مدتِ سرویسهای انتخابشده ساخته میشوند، پس این
|
||||
* سرویس همیشه سرویسی (service-based) کار میکند و هرگز شبکهٔ اسلات نمیسازد.
|
||||
*/
|
||||
final class ResourceBookingSlotService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResourceAvailabilityService $availability,
|
||||
private readonly ResourceOccupancyRepository $occupancy,
|
||||
private readonly AppointmentRepository $appointments,
|
||||
private readonly ResourceServiceOfferingRepository $offerings,
|
||||
private readonly ResourceServiceResolver $resolver,
|
||||
private readonly ServiceItemRepository $items,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* بازههای کاریِ منبع در یک روز — بدون کسر نوبتها.
|
||||
*
|
||||
* تایملاین به این نیاز دارد نه به وقت آزاد: نوبتِ ثبتشده باید **داخل** بازهٔ کاری
|
||||
* دیده شود، وگرنه کارت نوبت جایی برای نشستن ندارد.
|
||||
*
|
||||
* @return array{windows: list<TimeInterval>, 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<TimeInterval>
|
||||
*/
|
||||
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<array{start: int, end: int, start_time: string, end_time: string}>
|
||||
*/
|
||||
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<string> $serviceUuids
|
||||
* @param array<string, mixed> $overrides uuid => دقیقه
|
||||
* @return array{minutes: int, items: list<ServiceItem>}
|
||||
*
|
||||
* @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<array{start: int, end: int}>
|
||||
*/
|
||||
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<array{start: int, end: int}> $busy
|
||||
* @return list<TimeInterval>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceServiceOffering;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* ثبت نوبت **برای یک منبع** از پنل: همان `POST /api/v1/my/appointment`، با
|
||||
* `resource_uuid`.
|
||||
*
|
||||
* مدت از زنجیرهٔ حلِ همان منبع میآید نه از پیشفرضِ سرویس، پزشک از ناظرِ منبع
|
||||
* استنتاج میشود، و تداخل روی خودِ منبع جدا از اسلاتِ پزشک سنجیده میشود.
|
||||
*/
|
||||
class ResourceAppointmentCreateTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor, 2: ClinicResource, 3: DoctorAddress} */
|
||||
private function doctorWithResource(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceCalendar;
|
||||
use App\Resource\Entity\ResourceServiceOffering;
|
||||
use App\Shared\Context\EntityContext;
|
||||
|
||||
/**
|
||||
* زمانهای نوبتدهیِ یک منبع — همان چیزی که تایملاین و مودالِ «ثبت نوبت» صفحهٔ
|
||||
* نوبتها مصرف میکنند.
|
||||
*
|
||||
* منبع اسلات ثابت ندارد: بازهٔ کاری از تقویم خودش میآید و زمانها از مدتِ سرویسهای
|
||||
* انتخابشده ساخته میشوند.
|
||||
*/
|
||||
class ResourceBookingSlotTest extends ResourceTestCase
|
||||
{
|
||||
private const SHIFT_FROM = 540; // ۰۹:۰۰
|
||||
private const SHIFT_TO = 1020; // ۱۷:۰۰
|
||||
|
||||
/** @return array{0: User, 1: Doctor, 2: ClinicResource, 3: DoctorAddress} */
|
||||
private function resourceWithShift(): array
|
||||
{
|
||||
[$user, $doctor, $address] = $this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user