diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 9f2ba0c6..9e5c6950 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -81,6 +81,8 @@ import ResourcesPage from './pages/ResourcesPage'; import ResourceTypesPage from './pages/ResourceTypesPage'; import SkillsPage from './pages/SkillsPage'; import ResourcePoolsPage from './pages/ResourcePoolsPage'; +import ResourceCalendarPage from './pages/ResourceCalendarPage'; +import HolidaysSettingsPage from './pages/HolidaysSettingsPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PatientDetailPage from './pages/PatientDetailPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; @@ -289,6 +291,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 33cca747..03cef1c5 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -31,6 +31,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, { key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/hooks/useResourceCalendar.ts b/assets/admin/hooks/useResourceCalendar.ts new file mode 100644 index 00000000..2fa13e1c --- /dev/null +++ b/assets/admin/hooks/useResourceCalendar.ts @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { + HolidayOverride, NationalHoliday, ResourceAvailability, + ResourceCalendarDays, ResourceException, WorkingHoursPayload, +} from '../types'; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export function useResourceCalendar(resourceUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['resource-calendar', resourceUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/resource/${resourceUuid}/calendar`), + enabled: !!resourceUuid, + }); + + /** PUT جایگزینی کامل است: روزی که نفرستید خالی می‌شود. */ + const save = useMutation({ + mutationFn: (days: WorkingHoursPayload) => + api.put>(`/api/v1/resource/${resourceUuid}/calendar`, { days }), + onSuccess: () => { + toast.success('شیفت‌ها ذخیره شد'); + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] }); + }, + onError: (e) => fail(e, 'ذخیرهٔ شیفت‌ها ناموفق بود'), + }); + + return { calendar: query.data?.data, loading: query.isLoading, save }; +} + +export function useResourceExceptions(resourceUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['resource-exceptions', resourceUuid]; + + const invalidate = () => { + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] }); + }; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/resource/${resourceUuid}/exceptions`), + enabled: !!resourceUuid, + }); + + const create = useMutation({ + mutationFn: (d: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => + api.post>(`/api/v1/resource/${resourceUuid}/exception`, d), + onSuccess: () => { toast.success('استثنا ثبت شد'); invalidate(); }, + onError: (e) => fail(e, 'ثبت استثنا ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/resource-exception/${uuid}`), + onSuccess: () => { toast.success('استثنا حذف شد'); invalidate(); }, + onError: (e) => fail(e, 'حذف استثنا ناموفق بود'), + }); + + return { exceptions: query.data?.data ?? [], loading: query.isLoading, create, remove }; +} + +export function useResourceAvailability(resourceUuid: string | undefined, from: number, to: number) { + const query = useQuery({ + queryKey: ['resource-availability', resourceUuid, from, to], + queryFn: () => + api.get>(`/api/v1/resource/${resourceUuid}/availability?from=${from}&to=${to}`), + enabled: !!resourceUuid, + }); + + return { availability: query.data?.data, loading: query.isLoading }; +} + +export function useHolidays(year: number) { + const qc = useQueryClient(); + const key = ['national-holidays', year]; + + const query = useQuery({ + queryKey: key, + queryFn: () => + api.get>( + `/api/v1/national-holidays?year=${year}`, + ), + }); + + const setOverride = useMutation({ + mutationFn: (d: { date: number; is_working: boolean; note?: string | null }) => + api.post>('/api/v1/holiday-overrides', d), + onSuccess: () => { toast.success('استثنای تعطیلی ذخیره شد'); qc.invalidateQueries({ queryKey: key }); }, + onError: (e) => fail(e, 'ذخیرهٔ استثنا ناموفق بود'), + }); + + const removeOverride = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/holiday-override/${uuid}`), + onSuccess: () => { toast.success('استثنا حذف شد'); qc.invalidateQueries({ queryKey: key }); }, + onError: (e) => fail(e, 'حذف استثنا ناموفق بود'), + }); + + return { + holidays: query.data?.data?.holidays ?? [], + overrides: query.data?.data?.overrides ?? [], + loading: query.isLoading, + setOverride, + removeOverride, + }; +} diff --git a/assets/admin/pages/HolidaysSettingsPage.tsx b/assets/admin/pages/HolidaysSettingsPage.tsx new file mode 100644 index 00000000..25e372ea --- /dev/null +++ b/assets/admin/pages/HolidaysSettingsPage.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useHolidays } from '../hooks/useResourceCalendar'; +import { formatDate } from '../lib/utils'; +import type { NationalHoliday } from '../types'; + +/** سالِ شمسی جاری از همان فرمت‌کنندهٔ شمسیِ مرورگر گرفته می‌شود، نه با محاسبهٔ دستی. */ +function currentJalaliYear(): number { + const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date()); + return Number(formatted.replace(/\D/g, '')) || 1405; +} + +/** + * تعطیلات رسمی و استثناهای این محیط. + * + * خودِ تعطیلات کشوری‌اند و اینجا فقط دیده می‌شوند؛ آنچه محیط تغییر می‌دهد «باز بودن + * یا نبودنِ» همان روز برای خودش است. + */ +export default function HolidaysSettingsPage() { + const thisYear = currentJalaliYear(); + const [urlState, setUrlState] = useUrlState({ year: String(thisYear) }); + const year = Number(urlState.year) || thisYear; + + const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const overrideByDate = new Map(overrides.map((o) => [o.date, o])); + + const columns: Column[] = [ + { key: 'jalali_date', header: 'تاریخ', render: (h) => {h.jalali_date} }, + { key: 'title', header: 'مناسبت', render: (h) => {h.title} }, + { + key: 'gregorian', + header: 'میلادی', + render: (h) => {formatDate(h.date * 1000)}, + }, + { + key: 'status', + header: 'وضعیت این محیط', + render: (h) => + overrideByDate.get(h.date)?.is_working ? ( + باز است + ) : ( + تعطیل + ), + }, + ]; + + const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i); + + return ( +
+ + + + ({ value: String(y), label: String(y) }))} + value={String(year)} + onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })} + placeholder="سال" + height={36} + /> +
+ } + actions={ + canUpdate + ? (h) => { + const override = overrideByDate.get(h.date); + return ( +
+ {override?.is_working ? ( + + ) : ( + + )} +
+ ); + } + : undefined + } + /> + + !o.is_working)} + canUpdate={canUpdate} + saving={setOverride.isPending} + onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })} + onRemove={(uuid) => removeOverride.mutate(uuid)} + /> + + ); +} + +/** + * جهت دوم: روزی که تعطیل رسمی نیست ولی این محیط بسته است. + * این با «استثنای منبع» فرق دارد — آن روی یک منبع است و این روی کل محیط. + */ +function ClosureCard({ + overrides, canUpdate, saving, onAdd, onRemove, +}: { + overrides: { uuid: string; date: number; note: string | null }[]; + canUpdate: boolean; + saving: boolean; + onAdd: (date: number, note: string | null) => void; + onRemove: (uuid: string) => void; +}) { + const [date, setDate] = React.useState(''); + const [note, setNote] = React.useState(''); + + const timestamp = date === '' ? null : Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000); + + return ( +
+

تعطیلی‌های این محیط

+

+ روزهایی که تعطیل رسمی نیستند ولی این محیط بسته است. برای مرخصی یک نفر یا سرویس یک دستگاه، + از تقویم همان منبع استفاده کنید. +

+ + {overrides.length === 0 ? ( +

موردی ثبت نشده است.

+ ) : ( +
+ {overrides.map((o) => ( +
+ + {formatDate(o.date * 1000)}{o.note ? ` · ${o.note}` : ''} + + {canUpdate && ( + + )} +
+ ))} +
+ )} + + {canUpdate && ( +
+ setDate(e.target.value)} style={{ minWidth: 170 }} /> + setNote(e.target.value)} placeholder="توضیح (اختیاری)" style={{ flex: 1, minWidth: 180 }} /> + +
+ )} +
+ ); +} diff --git a/assets/admin/pages/ResourceCalendarPage.test.tsx b/assets/admin/pages/ResourceCalendarPage.test.tsx new file mode 100644 index 00000000..35c311d2 --- /dev/null +++ b/assets/admin/pages/ResourceCalendarPage.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, 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 {}, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) })); + +import { Routes, Route } from 'react-router-dom'; +import { api } from '../lib/api'; +import ResourceCalendarPage from './ResourceCalendarPage'; + +const get = api.get as ReturnType; + +const emptyDays = (): Record => + Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]])); + +function mockApi(days: Record, availabilityDays: unknown[]) { + get.mockImplementation((path: string) => { + if (path.endsWith('/calendar')) { + return Promise.resolve({ + success: true, + data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', defined: true, days }, + }); + } + if (path.includes('/availability')) { + return Promise.resolve({ + success: true, + data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays }, + }); + } + if (path.endsWith('/exceptions')) return Promise.resolve({ success: true, data: [] }); + if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [] }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/resources/r1/calendar' }, + ); +} + +describe('ResourceCalendarPage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders seven days and marks shiftless ones', async () => { + mockApi(emptyDays(), []); + renderPage(); + + await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); + expect(screen.getByText('جمعه')).toBeInTheDocument(); + expect(screen.getAllByText('بدون شیفت')).toHaveLength(7); + }); + + it('shows stored shifts as times', async () => { + const days = emptyDays(); + days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }]; + mockApi(days, []); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); + expect(screen.getByDisplayValue('17:00')).toBeInTheDocument(); + }); + + /** + * دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور + * («outside_branch_hours») به کاربر یعنی پیام بی‌معنا. + */ + it('translates every empty-day reason into Persian', async () => { + mockApi(emptyDays(), [ + { date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] }, + { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] }, + { date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] }, + ]); + renderPage(); + + await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument()); + expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument(); + expect(screen.getByText('مرخصی یا سرویس')).toBeInTheDocument(); + expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument(); + }); + + it('shows free minutes for a day that has availability', async () => { + mockApi(emptyDays(), [ + { date: 1785529800, day_of_week: 0, intervals: [{ start: 1, end: 2 }], total_minutes: 480, reasons: [] }, + ]); + renderPage(); + + await waitFor(() => expect(screen.getByText('480 دقیقه')).toBeInTheDocument()); + }); + + /** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */ + it('warns that the preview is raw availability', async () => { + mockApi(emptyDays(), []); + renderPage(); + + await waitFor(() => expect(screen.getByText(/نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند/)).toBeInTheDocument()); + }); +}); diff --git a/assets/admin/pages/ResourceCalendarPage.tsx b/assets/admin/pages/ResourceCalendarPage.tsx new file mode 100644 index 00000000..1c4c0fc5 --- /dev/null +++ b/assets/admin/pages/ResourceCalendarPage.tsx @@ -0,0 +1,376 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { usePermissions } from '../hooks/usePermissions'; +import { useResources } from '../hooks/useResources'; +import { + useResourceAvailability, useResourceCalendar, useResourceExceptions, +} from '../hooks/useResourceCalendar'; +import { formatDate } from '../lib/utils'; +import type { ResourceException } from '../types'; + +/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */ +const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; + +const MINUTES_IN_DAY = 1440; + +/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ +const REASON_LABELS: Record = { + national_holiday: 'تعطیل رسمی', + tenant_holiday: 'تعطیلی این محیط', + no_shift: 'شیفتی تعریف نشده', + branch_closed: 'شعبه این روز بسته است', + outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه', + exception: 'مرخصی یا سرویس', + resource_inactive: 'منبع غیرفعال است', + branch_inactive: 'شعبه غیرفعال است', +}; + +const EXCEPTION_TYPES = [ + { value: 'leave', label: 'مرخصی' }, + { value: 'absence', label: 'غیبت' }, + { value: 'maintenance', label: 'سرویس دوره‌ای' }, + { value: 'closure', label: 'تعطیلی موردی' }, +]; + +type Draft = { start: string; end: string; endOfDay: boolean }; + +function toTime(minute: number): string { + return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; +} + +function toMinutes(time: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!m) return null; + const minutes = Number(m[1]) * 60 + Number(m[2]); + return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; +} + +/** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */ +function todayMidnight(): number { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +/** + * تقویم یک منبع: شیفت هفتگی، استثناها، و پیش‌نمایش ساعت آزاد. + * + * پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن + * کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد. + */ +export default function ResourceCalendarPage() { + const { resourceUuid } = useParams<{ resourceUuid: string }>(); + const { calendar, loading, save } = useResourceCalendar(resourceUuid); + const { exceptions, create, remove } = useResourceExceptions(resourceUuid); + const { resources } = useResources(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const resource = resources.find((r) => r.uuid === resourceUuid); + + const [draft, setDraft] = useState>({}); + const [error, setError] = useState(null); + const [toDelete, setToDelete] = useState(null); + + const previewFrom = todayMidnight(); + const previewTo = previewFrom + 13 * 86400; + const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); + + useEffect(() => { + if (!calendar) return; + const next: Record = {}; + DAY_LABELS.forEach((_, day) => { + next[day] = (calendar.days[String(day)] ?? []).map((r) => ({ + start: toTime(r.start_minute), + end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute), + endOfDay: r.end_minute === MINUTES_IN_DAY, + })); + }); + setDraft(next); + }, [calendar]); + + const totalShifts = useMemo( + () => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0), + [draft], + ); + + const editRange = (day: number, index: number, patch: Partial) => + setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); + + const submit = () => { + const days: Record = {}; + + for (const [dayKey, rows] of Object.entries(draft)) { + const parsed: { start_minute: number; end_minute: number }[] = []; + + for (const row of rows) { + const start = toMinutes(row.start); + const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); + + if (start === null || end === null) { + setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); + return; + } + if (end <= start) { + setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`); + return; + } + parsed.push({ start_minute: start, end_minute: end }); + } + + days[dayKey] = parsed; + } + + setError(null); + save.mutate(days); + }; + + return ( +
+ + {save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'} + + ) : undefined + } + /> + + {error && ( +
+ {error} +
+ )} + +
+
+

+ شیفت هفتگی {totalShifts > 0 && ({totalShifts} شیفت)} +

+ + {loading ? ( +
در حال بارگذاری...
+ ) : ( + DAY_LABELS.map((label, day) => { + const rows = draft[day] ?? []; + return ( +
+
+
+ {label} + {rows.length === 0 && بدون شیفت} +
+ {canUpdate && ( + + )} +
+ +
+ {rows.map((row, index) => ( +
+ editRange(day, index, { start: e.target.value })} + style={{ width: 116 }} + /> + تا + {row.endOfDay ? ( + ۲۴:۰۰ + ) : ( + editRange(day, index, { end: e.target.value })} + style={{ width: 116 }} + /> + )} + + {canUpdate && ( + + )} +
+ ))} +
+
+ ); + }) + )} +
+ +
+ create.mutate(payload)} + onDelete={setToDelete} + /> + +
+

پیش‌نمایش دو هفته

+

+ ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند. +

+ +
+ {(availability?.days ?? []).map((day) => ( +
+ + {DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)} + + {day.intervals.length === 0 ? ( + + {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} + + ) : ( + {day.total_minutes} دقیقه + )} +
+ ))} +
+
+
+
+ + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function ExceptionsCard({ + exceptions, canUpdate, saving, onCreate, onDelete, +}: { + exceptions: ResourceException[]; + canUpdate: boolean; + saving: boolean; + onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void; + onDelete: (e: ResourceException) => void; +}) { + const [type, setType] = useState('leave'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [reason, setReason] = useState(''); + + const toTimestamp = (value: string): number | null => { + if (value === '') return null; + const ms = new Date(`${value}T00:00:00`).getTime(); + return Number.isNaN(ms) ? null : Math.floor(ms / 1000); + }; + + const start = toTimestamp(startDate); + const end = toTimestamp(endDate); + // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. + const endExclusive = end === null ? null : end + 86400; + const invalid = start === null || endExclusive === null || endExclusive <= start; + + return ( +
+

مرخصی و سرویس

+ + {exceptions.length === 0 ? ( +

استثنایی ثبت نشده است.

+ ) : ( +
+ {exceptions.map((e) => ( +
+ {e.type_label} + + {formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)} + {e.reason ? ` · ${e.reason}` : ''} + + {canUpdate && ( + + )} +
+ ))} +
+ )} + + {canUpdate && ( +
+ setType(v ? String(v) : 'leave')} + placeholder="نوع استثنا" + height={36} + /> +
+ setStartDate(e.target.value)} style={{ flex: 1 }} /> + setEndDate(e.target.value)} style={{ flex: 1 }} /> +
+ setReason(e.target.value)} placeholder="توضیح (اختیاری)" /> + +
+ )} +
+ ); +} diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index 1e9c7ac7..9cbb632c 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { PlusIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; @@ -182,6 +183,9 @@ export default function ResourcesPage() { + + تقویم + diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index b41418b7..d3d17655 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1025,3 +1025,67 @@ export interface ResourcePool { created_at: number; updated_at: number; } + +// ── تقویم منبع، استثنا و تعطیلات ───────────────────────────────────────────── + +export interface ResourceCalendarDays { + resource_uuid: string; + timezone: string; + defined: boolean; + /** کلیدهای `"0"`..`"6"`؛ ۰ = شنبه */ + days: Record; +} + +export type ResourceExceptionType = 'leave' | 'absence' | 'maintenance' | 'closure'; + +export interface ResourceException { + uuid: string; + resource_uuid: string; + resource_name: string; + type: ResourceExceptionType; + type_label: string; + /** timestamp مطلق — استثنا می‌تواند چندروزه باشد */ + starts_at: number; + ends_at: number; + reason: string | null; + created_at: number; + updated_at: number; +} + +export interface AvailabilityInterval { + start: number; + end: number; +} + +export interface AvailabilityDay { + date: number; + day_of_week: number; + intervals: AvailabilityInterval[]; + total_minutes: number; + /** چرا روز خالی است — بدون این، پاسخ خالی از باگ قابل تشخیص نیست */ + reasons: string[]; +} + +export interface ResourceAvailability { + resource_uuid: string; + timezone: string; + days: AvailabilityDay[]; +} + +export interface NationalHoliday { + uuid: string; + date: number; + jalali_date: string; + jalali_year: number; + title: string; + /** `null` یعنی این محیط استثنایی ندارد */ + overridden_working?: boolean | null; +} + +export interface HolidayOverride { + uuid: string; + date: number; + is_working: boolean; + note: string | null; + created_at: number; +} diff --git a/docs/api/README.md b/docs/api/README.md index 1e904773..31ca6c05 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -84,6 +84,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 | | [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 | | [resource.md](resource.md) | Resources, types, skills, pools | 16 | +| [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md new file mode 100644 index 00000000..1e021cb4 --- /dev/null +++ b/docs/api/resource-calendar.md @@ -0,0 +1,246 @@ +# Resource Calendar API — تقویم منبع، استثنا و تعطیلات + +> **Base:** `/api/v1` · **Auth:** JWT · **مجوز:** `appointment_settings` +> مکمل [resource.md](resource.md) — منبع آنجا ساخته می‌شود، تقویمش اینجا. + +--- + +## کسرِ لایه‌ها + +بند ۹ مستند ساعت آزاد را از کسر هفت لایه می‌سازد. آنچه این بخش می‌دهد **چهار لایهٔ اول** است: + +``` +ساعت کاری شعبه ∩ شیفت منبع − تعطیلات رسمی − استثناهای منبع +``` + +**نوبت‌های ثبت‌شده و رزروهای موقت اینجا کسر نمی‌شوند.** خروجی این اندپوینت «وقت قابل +رزرو» **نیست**؛ تقاطع چند منبع و کسر اشغال کارِ تسک‌های بعدی است. متد داخلی‌اش عمداً +`rawAvailability()` نام دارد. + +مسیر اسلاتیِ موجود (`SlotCalculatorService`، `WeeklySchedule`، `DateOverride`، `Holiday`) +در این فاز **دست‌نخورده** است؛ این یک مسیر موازی روی منبع است. + +--- + +## `GET /api/v1/resource/{uuid}/calendar` + +```json +{ + "success": true, + "data": { + "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", + "timezone": "Asia/Tehran", + "defined": true, + "days": { + "0": [{ "sequence": 0, "start_minute": 540, "end_minute": 1020, "start_time": "09:00", "end_time": "17:00", "active": true }], + "1": [{ "sequence": 0, "start_minute": 540, "end_minute": 1020, "start_time": "09:00", "end_time": "17:00", "active": true }], + "2": [], "3": [], "4": [], "5": [], "6": [] + } + } +} +``` + +`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است؛ ۰ = شنبه. `timezone` از شعبهٔ منبع +می‌آید و مبنای «روز» در محاسبهٔ ساعت آزاد است. + +## `PUT /api/v1/resource/{uuid}/calendar` + +جایگزینی کامل هفت روز — روزی که نفرستید خالی می‌شود. قواعد و خطاها دقیقاً مثل ساعت +کاری شعبه ([branch.md](branch.md)): دقیقه از نیمه‌شب `0..1440`، `end > start`، بدون +هم‌پوشانی در یک روز، `sequence` را سرور می‌دهد، و **اعتبارسنجی کامل پیش از هر حذفی**. + +```json +{ "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } } +``` + +--- + +## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی + +چهار نوع، **یک جدول**: هر چهار «یک بازهٔ کسرشونده از تقویم منبع»اند و جدا کردنشان یعنی +چهار کوئری در هر محاسبه به‌جای یکی. + +| `type` | برچسب | +|---|---| +| `leave` | مرخصی | +| `absence` | غیبت | +| `maintenance` | سرویس دوره‌ای | +| `closure` | تعطیلی موردی | + +زمان‌ها **timestamp مطلق**اند، نه دقیقه‌از-نیمه‌شب: یک مرخصی می‌تواند چندروزه باشد. + +### `GET /api/v1/resource/{uuid}/exceptions?from&to` + +بازه اختیاری است؛ استثناهایی که با آن **تداخل** دارند برمی‌گردند، نه فقط آن‌هایی که +کاملاً درونش‌اند — وگرنه مرخصیِ سه‌روزه‌ای که وسطش این بازه است دیده نمی‌شد. + +### `POST /api/v1/resource/{uuid}/exception` + +**۲۰۱** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "b387529b-c27c-4c6c-ab6c-047167ae51da", + "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", + "resource_name": "لیزر آلکساندرایت ۱", + "type": "maintenance", + "type_label": "سرویس دوره‌ای", + "starts_at": 1785509280, + "ends_at": 1785512880, + "reason": "سرویس دوره‌ای", + "created_at": 1785422880, + "updated_at": 1785422880 + } +} +``` + +**۴۲۲:** `ends_at <= starts_at` (field `ends_at`) · نوع ناشناخته (field `type`) · +نبودِ `starts_at`/`ends_at`. **۴۰۴:** منبع یا استثنای محیط دیگر. + +### `PATCH` / `DELETE /api/v1/resource-exception/{uuid}` + +دو استثنای هم‌پوشان **مجازند** و در محاسبه اتحادشان گرفته می‌شود؛ خطا نیست. + +--- + +## `GET /api/v1/resource/{uuid}/availability?from&to` + +`from` و `to` هر دو timestamp‌اند و هر دو روز **شامل**اند. سقف بازه ۹۲ روز است. + +**۲۰۰** (خروجی واقعی برای دو روز): + +```json +{ + "success": true, + "data": { + "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", + "timezone": "Asia/Tehran", + "days": [ + { "date": 1785529800, "day_of_week": 0, "intervals": [{ "start": 1785562200, "end": 1785591000 }], "total_minutes": 480, "reasons": [] }, + { "date": 1785616200, "day_of_week": 1, "intervals": [{ "start": 1785648600, "end": 1785677400 }], "total_minutes": 480, "reasons": [] } + ] + } +} +``` + +### `reasons` — چرا روز خالی است + +بدون این، پاسخِ خالی از یک باگ قابل تشخیص نیست. + +| کد | یعنی | +|---|---| +| `no_shift` | منبع آن روز شیفتی ندارد | +| `branch_closed` | شعبه آن روز ساعت کاری ندارد | +| `outside_branch_hours` | شیفت هست ولی تقاطعش با ساعت شعبه خالی شد | +| `national_holiday` | تعطیل رسمی کشور | +| `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده | +| `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده | +| `resource_inactive` / `branch_inactive` | منبع یا شعبه غیرفعال است | + +**۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازهٔ درخواستی حداکثر 92 روز است","field":"to"}]} +``` + +### دو قرارداد مهم + +**شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «بسته».** شیفت منبع بی‌قید اعمال می‌شود تا +دادهٔ موجود دقیقاً مثل امروز کار کند. این با `branch_closed` — که یعنی ساعت تعریف شده +ولی آن روز خالی است — فرق دارد. + +**شیفت بیرون از ساعت شعبه رد نمی‌شود، تقاطع گرفته می‌شود.** شیفت ۹–۱۷ روی شعبه‌ای که +۱۰–۱۲ باز است، ۱۲۰ دقیقه می‌دهد. + +--- + +## تعطیلات رسمی + +### `GET /api/v1/national-holidays?year=1405` + +تعطیلات **سراسری**اند و به محیط تعلق ندارند؛ امروز هر پزشک باید ۱۳ فروردین را دستی +ثبت کند، که هم تکرار است و هم منبع خطا. + +```json +{ + "success": true, + "data": { + "year": 1405, + "holidays": [ + { "uuid": "f1af4e18-…", "date": 1774040400, "jalali_date": "1405-01-01", "jalali_year": 1405, "title": "نوروز", "overridden_working": null } + ], + "overrides": [] + } +} +``` + +`overridden_working` کنار خودِ تعطیلی می‌آید تا UI مجبور نباشد دو فهرست را تطبیق دهد: +`null` یعنی این محیط استثنایی ندارد، `true` یعنی آن روز باز است. + +### `POST /api/v1/holiday-overrides` + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | int | هر لحظه از آن روز؛ سرور به نیمه‌شب تهران گرد می‌کند | +| `is_working` | bool | جهت استثنا | +| `note` | string\|null | | + +**دو جهت دارد و هر دو لازم‌اند:** + +- `true` → کلینیکی که آن روزِ تعطیلِ رسمی **باز** است +- `false` → روزی که رسمی نیست ولی این محیط **بسته** است + +جهت دوم با «استثنای منبع» فرق دارد: آن روی **یک منبع** است و این روی **کل محیط**. + +فرستادن دوباره روی همان تاریخ، همان ردیف را عوض می‌کند (کلید یکتا per محیط و تاریخ). + +### `DELETE /api/v1/holiday-override/{uuid}` + +--- + +## `app:holiday:import` + +```bash +ddev exec php bin/console app:holiday:import --year=1405 # dry-run +ddev exec php bin/console app:holiday:import --year=1405 --force +ddev exec php bin/console app:holiday:import --year=1405 --force --replace +ddev exec php bin/console app:holiday:import --year=1405 --force --extra=10/12/عید فطر +``` + +مناسبت‌های **ثابتِ شمسی** درون خودِ دستورند چون هر سال تکرار می‌شوند. مناسبت‌های +**قمری** (عید فطر، عاشورا، …) هر سال جابه‌جا می‌شوند و عمداً نیامده‌اند: حدس زدنشان +بدتر از نداشتنشان است — با `--extra` یا از پنل اضافه می‌شوند. + +⚠️ **`--replace` کِی لازم است:** ثبت روی `date` کلید می‌خورد، پس ردیفی که با تاریخ +**غلط** نوشته شده هرگز خودش را تصحیح نمی‌کند؛ اجرای دوباره ردیف درست را کنارش می‌سازد و +غلط سرِ جایش می‌ماند. دقیقاً همین پس از اصلاح باگ تبدیل شمسی پیش آمد. + +> **باگ تبدیل شمسی:** `JalaliDateService::gregorianToJalali()` غلط بود و برای +> ۲۰۲۶-۰۷-۳۰ مقدار `[3006, 7, 3]` می‌داد به‌جای `[1405, 5, 8]`. `jalaliYear()`، +> `jalaliMonthRange()` و `jalaliYearRange()` — که گزارش‌های نمایندگی رویشان ساخته +> شده‌اند — همه همین را به ارث می‌بردند. حالا هر دو تبدیل از `IntlDateFormatter` +> می‌آیند (همان چیزی که `formatDateTime()` همین کلاس از قبل درست استفاده می‌کرد) و +> `tests/Representation/JalaliDateServiceTest.php` قفلش می‌کند. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `resource_calendars` | `AGGREGATE_CHILDREN` — ریشه `ClinicResource` | +| `resource_exceptions` | جفت محیط (uuidش از request می‌آید) | +| `tenant_holiday_overrides` | جفت محیط | +| `national_holidays` | `GlobalTables::ENTITIES` — کشوری است، نه محیطی | + +--- + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Resource/ResourceAvailabilityTest.php # ۱۳ تست +ddev exec php bin/phpunit tests/Representation/JalaliDateServiceTest.php +npx vitest run assets/admin/pages/ResourceCalendarPage.test.tsx +``` diff --git a/docs/new_feture/taskes/task-03-resource-calendar/checklist.md b/docs/new_feture/taskes/task-03-resource-calendar/checklist.md index ee8d670c..a9988075 100644 --- a/docs/new_feture/taskes/task-03-resource-calendar/checklist.md +++ b/docs/new_feture/taskes/task-03-resource-calendar/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۳ (تقویم منبع، استثنا، تعطیلات ملی) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تکمیل‌شده (۲ ردیف 🔄 بازبینی چشمی · ۲ ردیف ⏳ با مقصد) · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,103 +11,103 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `SlotCalculatorService` **هیچ** تغییری نکرد | ⏳ | `ResourceAvailabilityService` سرویس موازی است | -| ۰.۳ | `WeeklySchedule` · `DateOverride` · `Holiday` موجود دست‌نخورده | ⏳ | | -| ۰.۴ | `HolidayResolver` به مسیر اسلاتی **وصل نشد** | ⏳ | تغییر رفتاری است؛ تسک جدا با تأیید محصول | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | ۳ تست / ۸ assertion | +| ۰.۲ | `SlotCalculatorService` **هیچ** تغییری نکرد | ✅ | صفر تغییر؛ `ResourceAvailabilityService` مسیر موازی است | +| ۰.۳ | `WeeklySchedule` · `DateOverride` · `Holiday` موجود دست‌نخورده | ✅ | هیچ‌کدام دست نخوردند | +| ۰.۴ | `HolidayResolver` به مسیر اسلاتی **وصل نشد** | ✅ | وصل **نشد** — تغییر رفتاری است و به تسک جدا موکول شد | ## ۱. `IntervalSet` — اول از همه | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `src/Shared/Time/IntervalSet.php` ساخته شد **پیش از** entity ها | ⏳ | | -| ۱.۲ | قرارداد نیم‌باز `[start, end)` همه‌جا | ⏳ | | -| ۱.۳ | `IntervalSetTest` — هفت حالت مرزی سند | ⏳ | واحد، بدون DB | -| ۱.۴ | هر جای دیگری که بازه جمع/کم می‌کند از همین استفاده می‌کند | ⏳ | نه پیاده‌سازی دوم | +| ۱.۱ | `src/Shared/Time/IntervalSet.php` ساخته شد **پیش از** entity ها | ✅ | نامش `TimeInterval` شد؛ در `src/Shared/Time/` تا تسک‌های ۰۵/۰۶ همین را مصرف کنند | +| ۱.۲ | قرارداد نیم‌باز `[start, end)` همه‌جا | ✅ | نیم‌باز؛ «۱۳:۰۰ پایان» و «۱۳:۰۰ شروع» تداخل ندارند | +| ۱.۳ | `IntervalSetTest` — هفت حالت مرزی سند | ✅ | `tests/Shared/TimeIntervalTest.php` — ۱۲ تست واحد، بدون DB | +| ۱.۴ | هر جای دیگری که بازه جمع/کم می‌کند از همین استفاده می‌کند | ✅ | تقاطع ساعت شعبه و کسر استثنا هر دو از همین می‌آیند | ## ۲. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `ResourceCalendar` · `ResourceException` | ⏳ | | -| ۲.۲ | `NationalHoliday` · `TenantHolidayOverride` | ⏳ | | -| ۲.۳ | `ResourceAvailabilityService::rawWindows()` + `explainEmptyDay()` | ⏳ | پنج دلیل خالی‌بودن | -| ۲.۴ | `HolidayResolver::isClosedFor()` — تنها نقطهٔ ترکیب | ⏳ | | -| ۲.۵ | ترتیب تعطیلی: ملی + override محیط؛ منبع فقط می‌بندد نه باز می‌کند | ⏳ | | -| ۲.۶ | شعبهٔ بدون ساعت کاری = بی‌قید، **نه بسته** | ⏳ | همان تصمیم تسک ۰۱ | -| ۲.۷ | `union` استثناها بعد `subtract` — نه کسر پشت‌سرهم | ⏳ | | -| ۲.۸ | تبدیل روز هفته `(w+1)%7` عیناً مثل `SlotCalculatorService:359` | ⏳ | دو قرارداد = باگ قطعی | -| ۲.۹ | `DateTimeZone` صریح از `branch.timezone`، نه `date()` خالی | ⏳ | | -| ۲.۱۰ | نُه endpoint | ⏳ | | -| ۲.۱۱ | `app:holiday:import --year=` — idempotent | ⏳ | | -| ۲.۱۲ | `app:resource:calendar:backfill` از `WeeklySchedule` — dry-run | ⏳ | | +| ۲.۱ | `ResourceCalendar` · `ResourceException` | ✅ | | +| ۲.۲ | `NationalHoliday` · `TenantHolidayOverride` | ✅ | | +| ۲.۳ | `ResourceAvailabilityService::rawWindows()` + `explainEmptyDay()` | ✅ | نامش `rawAvailability()`؛ هشت دلیل خالی‌بودن، نه پنج | +| ۲.۴ | `HolidayResolver::isClosedFor()` — تنها نقطهٔ ترکیب | ✅ | به‌جای کلاس جدا، همان منطق در `ResourceAvailabilityService::buildDay()` متمرکز است — کلاسی که فقط یک بار و از یک نقطه صدا زده شود، لایه‌ای بی‌سود می‌ساخت | +| ۲.۵ | ترتیب تعطیلی: ملی + override محیط؛ منبع فقط می‌بندد نه باز می‌کند | ✅ | override محیط بر تعطیل ملی مقدم است، در هر دو جهت | +| ۲.۶ | شعبهٔ بدون ساعت کاری = بی‌قید، **نه بسته** | ✅ | `branch_closed` با «تعریف‌نشده» فرق دارد و تست جدا دارد | +| ۲.۷ | `union` استثناها بعد `subtract` — نه کسر پشت‌سرهم | ✅ | `subtractAll` اول اتحاد می‌گیرد — تست واحد دارد | +| ۲.۸ | تبدیل روز هفته `(w+1)%7` عیناً مثل `SlotCalculatorService:359` | ✅ | `(w+1)%7` عیناً همان | +| ۲.۹ | `DateTimeZone` صریح از `branch.timezone`، نه `date()` خالی | ✅ | از `address.timezone`؛ روز بعد هم با تقویم گرفته می‌شود نه `+86400` | +| ۲.۱۰ | نُه endpoint | ✅ | نُه endpoint | +| ۲.۱۱ | `app:holiday:import --year=` — idempotent | ✅ | + `--replace` که در طرح نبود و لازم شد | +| ۲.۱۲ | `app:resource:calendar:backfill` از `WeeklySchedule` — dry-run | ✅ | `app:resource:calendar:backfill` — منبعِ تنظیم‌شدهٔ کاربر را دست نمی‌زند | ## ۳. کارایی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `rawWindows` برای ۹۰ روز **دقیقاً ۵ کوئری** | ⏳ | | -| ۳.۲ | `AvailabilityQueryCountTest` عدد را قفل می‌کند | ⏳ | وگرنه اولین refactor می‌شکند | -| ۳.۳ | هیچ کوئری داخل حلقهٔ روز | ⏳ | | -| ۳.۴ | `idx_rex_resource_range` ساخته شد | ⏳ | | +| ۳.۱ | `rawWindows` برای ۹۰ روز **دقیقاً ۵ کوئری** | ✅ | تعداد کوئری ۱ روز و ۹۰ روز **برابر** است | +| ۳.۲ | `AvailabilityQueryCountTest` عدد را قفل می‌کند | ✅ | `AvailabilityQueryCountTest` | +| ۳.۳ | هیچ کوئری داخل حلقهٔ روز | ✅ | شیفت/ساعت شعبه/تعطیلات/استثناها همه یک بار پیش از حلقه | +| ۳.۴ | `idx_rex_resource_range` ساخته شد | ✅ | | ## ۴. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | چهار جدول | ⏳ | | -| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | ⏳ | شیفت فصلی | -| ۴.۳ | نوع `blocked` در استثناها هست | ⏳ | تفکیک از مرخصی برای گزارش تسک ۱۴ | -| ۴.۴ | `national_holidays` در `GlobalTables::ENTITIES` با دلیل | ⏳ | | -| ۴.۵ | `resource_calendars` در `AGGREGATE_CHILDREN` | ⏳ | | -| ۴.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۴.۱ | چهار جدول | ✅ | `Version20260730143130` | +| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | ⏳ | شیفت فصلی پیاده نشد. دلیلِ «از روز اول لازم است» برقرار نیست: دو ستون تهی‌پذیر بعداً بدون backfill اضافه می‌شوند (NULL = همیشه معتبر). مقصد: هر تسکی که واقعاً شیفت فصلی بخواهد | +| ۴.۳ | نوع `blocked` در استثناها هست | ✅ | `closure` همان نقش را دارد؛ چهار نوع: leave/absence/maintenance/closure | +| ۴.۴ | `national_holidays` در `GlobalTables::ENTITIES` با دلیل | ✅ | | +| ۴.۵ | `resource_calendars` در `AGGREGATE_CHILDREN` | ✅ | | +| ۴.۶ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۵. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `ResourceCalendarPage` · `ResourceExceptionsPage` · `HolidaysSettingsPage` | ⏳ | | -| ۵.۲ | گرید هفت‌روزه با فرم ساده (بدون drag) | ⏳ | | -| ۵.۳ | بازهٔ تاریخ با `PersianDatePicker` | ⏳ | | -| ۵.۴ | `backTo`/`BackButton` روی هر سه | ⏳ | | -| ۵.۵ | تعطیلات سال با تیک «ما این روز کار می‌کنیم» | ⏳ | | -| ۵.۶ | هیچ رنگ/شعاع hard-code | ⏳ | | -| ۵.۷ | دارک‌مود و حالت فشرده | ⏳ | | -| ۵.۸ | RTL و موبایل | ⏳ | | -| ۵.۹ | دلیل خالی‌بودن روز با پیام فارسی نمایش داده می‌شود | ⏳ | پنج حالت | +| ۵.۱ | `ResourceCalendarPage` · `ResourceExceptionsPage` · `HolidaysSettingsPage` | ✅ | `ResourceCalendarPage` (شامل استثناها) + `HolidaysSettingsPage` | +| ۵.۲ | گرید هفت‌روزه با فرم ساده (بدون drag) | ✅ | گرید هفت‌روزه با فرم ساده | +| ۵.۳ | بازهٔ تاریخ با `PersianDatePicker` | ⏳ | ورودی تاریخ فعلاً `` میلادی است، نه `PersianDatePicker`. مقصد: پاس UI فارسی‌سازی | +| ۵.۴ | `backTo`/`BackButton` روی هر سه | ✅ | `backTo` + breadcrumb | +| ۵.۵ | تعطیلات سال با تیک «ما این روز کار می‌کنیم» | ✅ | دکمهٔ «این روز بازیم» + بخش تعطیلی‌های خود محیط | +| ۵.۶ | هیچ رنگ/شعاع hard-code | ✅ | همه از `var(--…)` | +| ۵.۷ | دارک‌مود و حالت فشرده | 🔄 | کد فقط از توکن استفاده می‌کند؛ بازبینی چشمی در مرورگر انجام نشد | +| ۵.۸ | RTL و موبایل | 🔄 | چیدمان grid با wrap؛ بازبینی چشمی موبایل انجام نشد | +| ۵.۹ | دلیل خالی‌بودن روز با پیام فارسی نمایش داده می‌شود | ✅ | هر هشت دلیل ترجمهٔ فارسی دارد؛ تست کامپوننت قفلش می‌کند | ## ۶. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | `IntervalSetTest` | ⏳ | | -| ۶.۲ | `ResourceCalendarTest` — `end<=start`، هم‌پوشانی، شیفت شبانه → ۴۲۲ | ⏳ | | -| ۶.۳ | `ResourceAvailabilityTest` — تقاطع، شعبهٔ بی‌ساعت، نیم‌روزه، دو استثنای هم‌پوشان | ⏳ | | -| ۶.۴ | `AvailabilityQueryCountTest` | ⏳ | | -| ۶.۵ | `HolidayResolverTest` — ملی، override دو جهت | ⏳ | | -| ۶.۶ | `ImportNationalHolidaysTest` — idempotent | ⏳ | | -| ۶.۷ | بازهٔ > ۹۰ روز → ۴۲۲ | ⏳ | | +| ۶.۱ | `IntervalSetTest` | ✅ | ۱۲ تست | +| ۶.۲ | `ResourceCalendarTest` — `end<=start`، هم‌پوشانی، شیفت شبانه → ۴۲۲ | ✅ | در `ResourceCalendarTest` نیامد؛ همان قواعد از مسیر `PUT /calendar` در تست‌های ساعت کاری و availability پوشش دارد. شیفت شبانه پشتیبانی نمی‌شود (رجوع به ۷.۳) | +| ۶.۳ | `ResourceAvailabilityTest` — تقاطع، شعبهٔ بی‌ساعت، نیم‌روزه، دو استثنای هم‌پوشان | ✅ | ۱۳ تست | +| ۶.۴ | `AvailabilityQueryCountTest` | ✅ | | +| ۶.۵ | `HolidayResolverTest` — ملی، override دو جهت | ✅ | دو جهت override + تعطیل ملی در `ResourceAvailabilityTest` سنجیده شد، نه در کلاس جدا | +| ۶.۶ | `ImportNationalHolidaysTest` — idempotent | ✅ | idempotent + `--replace` | +| ۶.۷ | بازهٔ > ۹۰ روز → ۴۲۲ | ✅ | سقف ۹۲ روز | ## ۷. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۷.۱ | `docs/api/resource-calendar.md` | ⏳ | | -| ۷.۲ | `docs/api/appointment-settings.md` بخش «تفاوت با تقویم منبع» | ⏳ | دو سیستم قاطی نشوند | -| ۷.۳ | تصمیم «شیفت شبانه پشتیبانی نمی‌شود» مکتوب | ⏳ | | +| ۷.۱ | `docs/api/resource-calendar.md` | ✅ | JSON واقعی از curl + ثبت در README | +| ۷.۲ | `docs/api/appointment-settings.md` بخش «تفاوت با تقویم منبع» | ✅ | بخش «کسرِ لایه‌ها» در `resource-calendar.md` صریح می‌گوید مسیر اسلاتی دست‌نخورده و موازی است | +| ۷.۳ | تصمیم «شیفت شبانه پشتیبانی نمی‌شود» مکتوب | ✅ | `end_minute` سقفش ۱۴۴۰ است، پس شیفتی که از نیمه‌شب رد شود بیان نمی‌شود — در سند نوشته شد | ## ۸. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۸.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۸.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۸.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۸.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۸.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۸.۶ | تست‌های tenant سبز | ⏳ | | -| ۸.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۸.۸ | چک‌لیست UI کامل | ⏳ | | -| ۸.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | | -| ۸.۱۰ | commit، سپس `graphify update .` | ⏳ | | -| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | اتصال `HolidayResolver` به مسیر اسلاتی | +| ۸.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⚠️ | دو 🔄 بازبینی چشمی + دو ⏳ با مقصد صریح | +| ۸.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۱۵۴ تست / ۳۲۲۹ assertion | +| ۸.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۸.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ خطا قبل و بعد؛ هیچ‌کدام در فایل‌های این تسک | +| ۸.۵ | `npx tsc --noEmit` و `yarn test` سبز | ✅ | `tsc` تمیز · vitest ۶۲۲ تست | +| ۸.۶ | تست‌های tenant سبز | ✅ | | +| ۸.۷ | `docs/api/*` به‌روز | ✅ | | +| ۸.۸ | چک‌لیست UI کامل | ⚠️ | ردیف‌های ۵.۳/۵.۷/۵.۸ | +| ۸.۹ | دو کلاینت دیگر بررسی شدند | ✅ | هیچ‌کدام تقویم منبع را مصرف نمی‌کنند؛ اندپوینت‌ها همه پنل‌محورند و قرارداد موجودی عوض نشد | +| ۸.۱۰ | commit، سپس `graphify update .` | ✅ | | +| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | شیفت فصلی (۴.۲) · تاریخ شمسی در فرم (۵.۳) · اتصال تعطیلات به مسیر اسلاتی (۰.۴) | diff --git a/src/Resource/Command/BackfillResourceCalendarCommand.php b/src/Resource/Command/BackfillResourceCalendarCommand.php new file mode 100644 index 00000000..e60e1dd2 --- /dev/null +++ b/src/Resource/Command/BackfillResourceCalendarCommand.php @@ -0,0 +1,169 @@ +addOption('force', null, InputOption::VALUE_NONE, 'Actually write; without it the command only reports'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = (bool) $input->getOption('force'); + + if (!$force) { + $io->note('Dry run — nothing will be written. Re-run with --force to apply.'); + } + + $rows = []; + $skipped = 0; + + foreach ($this->em->getRepository(WeeklySchedule::class)->findAll() as $schedule) { + $doctor = $schedule->getDoctor(); + + foreach ($this->shiftsByLocation($schedule) as $locationId => $shifts) { + $resource = $this->resourceFor($doctor, $locationId); + + if ($resource === null) { + continue; // منبعی برای این آدرس ساخته نشده؛ اول app:resource:backfill + } + + if ($this->calendars->findForResource($resource) !== []) { + $skipped++; + continue; // کاربر خودش تنظیم کرده — دست نمی‌زنیم + } + + $rows[] = [$resource->getName(), $resource->getAddress()->getName() ?? '—', count($shifts)]; + + if ($force) { + foreach ($shifts as $shift) { + $this->em->persist(new ResourceCalendar( + $resource, + $shift['day'], + $shift['start'], + $shift['end'], + $shift['sequence'], + )); + } + } + } + } + + if ($force) { + $this->em->flush(); + } + + if ($rows !== []) { + $io->table(['منبع', 'شعبه', 'تعداد شیفت'], $rows); + } + + $io->success(sprintf( + '%s — %d منبع · %d منبع از قبل شیفت داشت و دست‌نخورده ماند', + $force ? 'ساخته شد' : 'ساخته می‌شود', + count($rows), + $skipped, + )); + + return Command::SUCCESS; + } + + private function resourceFor(\App\Doctor\Entity\Doctor $doctor, int $locationId): ?ClinicResource + { + $address = $this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($locationId); + + return $address === null ? null : $this->resources->findForSubject($doctor, $address); + } + + /** + * @return array> + * شناسهٔ آدرس => شیفت‌ها + */ + private function shiftsByLocation(WeeklySchedule $schedule): array + { + $byLocation = []; + + foreach ($schedule->getSetting() as $rawDay => $day) { + if (!is_numeric($rawDay)) { + continue; + } + + $sequences = []; + + foreach (($day['sessions'] ?? []) as $session) { + if (!($session['active'] ?? false)) { + continue; + } + + $locationId = $session['location_id'] ?? null; + $start = $this->toMinutes($session['start_time'] ?? null); + $end = $this->toMinutes($session['end_time'] ?? null); + + if (!is_numeric($locationId) || $start === null || $end === null || $end <= $start) { + continue; + } + + $locationId = (int) $locationId; + $sequences[$locationId] ??= 0; + + $byLocation[$locationId][] = [ + 'day' => (int) $rawDay, + 'start' => $start, + 'end' => $end, + 'sequence' => $sequences[$locationId]++, + ]; + } + } + + return $byLocation; + } + + private function toMinutes(mixed $time): ?int + { + if (!is_string($time) || preg_match('/^(\d{1,2}):(\d{2})$/', $time, $m) !== 1) { + return null; + } + + $minutes = (int) $m[1] * 60 + (int) $m[2]; + + return $minutes >= 0 && $minutes <= ResourceCalendar::MINUTES_IN_DAY ? $minutes : null; + } +} diff --git a/src/Resource/Command/ImportHolidayCommand.php b/src/Resource/Command/ImportHolidayCommand.php index fb3ccdd6..c267538f 100644 --- a/src/Resource/Command/ImportHolidayCommand.php +++ b/src/Resource/Command/ImportHolidayCommand.php @@ -48,6 +48,12 @@ class ImportHolidayCommand extends Command { $this->addOption('year', null, InputOption::VALUE_REQUIRED, 'Jalali year, e.g. 1405'); $this->addOption('force', null, InputOption::VALUE_NONE, 'Actually write; without it the command only reports'); + $this->addOption( + 'replace', + null, + InputOption::VALUE_NONE, + 'Delete this year rows first — use when previously imported dates were wrong', + ); $this->addOption( 'extra', null, @@ -88,6 +94,14 @@ class ImportHolidayCommand extends Command $io->note('Dry run — nothing will be written. Re-run with --force to apply.'); } + // upsert روی `date` کلید می‌خورد، پس ردیفی که با تاریخِ **غلط** نوشته شده هرگز + // خودش را تصحیح نمی‌کند: اجرای دوباره ردیف درست را کنارش می‌سازد و غلط سرِ + // جایش می‌ماند. دقیقاً همین بعد از اصلاح باگ تبدیل شمسی پیش آمد. + if ($force && $input->getOption('replace')) { + $removed = $this->holidays->purgeYear($year); + $io->note(sprintf('%d ردیف سال %d پاک شد.', $removed, $year)); + } + $rows = []; foreach ($entries as [$month, $day, $title]) { diff --git a/src/Resource/Service/HolidayService.php b/src/Resource/Service/HolidayService.php index 37a3a625..cf3bf609 100644 --- a/src/Resource/Service/HolidayService.php +++ b/src/Resource/Service/HolidayService.php @@ -76,6 +76,17 @@ final class HolidayService return $holiday; } + /** + * همهٔ تعطیلات یک سال را پاک می‌کند — تنها راه تصحیحِ ردیفی که با تاریخ غلط + * نوشته شده، چون upsert روی `date` کلید می‌خورد و ردیف غلط را نمی‌شناسد. + */ + public function purgeYear(int $jalaliYear): int + { + return (int) $this->em->createQuery( + 'DELETE FROM App\Resource\Entity\NationalHoliday h WHERE h.jalaliYear = :year' + )->setParameter('year', $jalaliYear)->execute(); + } + /** @return NationalHoliday[] */ public function forYear(int $jalaliYear): array { diff --git a/src/Resource/Service/ResourceAvailabilityService.php b/src/Resource/Service/ResourceAvailabilityService.php index c08eb13e..5b3a7938 100644 --- a/src/Resource/Service/ResourceAvailabilityService.php +++ b/src/Resource/Service/ResourceAvailabilityService.php @@ -10,7 +10,7 @@ use App\Resource\Repository\ResourceCalendarRepository; use App\Resource\Repository\ResourceExceptionRepository; use App\Resource\Repository\TenantHolidayOverrideRepository; use App\Resource\ValueObject\DayAvailability; -use App\Resource\ValueObject\TimeInterval; +use App\Shared\Time\TimeInterval; /** * «ساعت آزادِ خامِ یک منبع در یک بازه» — لایه‌های ۱ تا ۴ از کسرِ بند ۹ مستند: diff --git a/src/Resource/ValueObject/DayAvailability.php b/src/Resource/ValueObject/DayAvailability.php index ee2ca230..b4b3d0ae 100644 --- a/src/Resource/ValueObject/DayAvailability.php +++ b/src/Resource/ValueObject/DayAvailability.php @@ -2,6 +2,8 @@ namespace App\Resource\ValueObject; +use App\Shared\Time\TimeInterval; + /** * ساعت آزادِ خامِ یک منبع در یک روز. * diff --git a/src/Resource/ValueObject/TimeInterval.php b/src/Shared/Time/TimeInterval.php similarity index 99% rename from src/Resource/ValueObject/TimeInterval.php rename to src/Shared/Time/TimeInterval.php index a01c07c0..008c72b2 100644 --- a/src/Resource/ValueObject/TimeInterval.php +++ b/src/Shared/Time/TimeInterval.php @@ -1,6 +1,6 @@ setTime(0, 0) + ->getTimestamp(); + } + + public function testQueryCountDoesNotGrowWithTheNumberOfDays(): void + { + // یک کرنل برای هر دو اندازه‌گیری، وگرنه reboot دادهٔ کوئری‌ها را می‌ریزد. + $this->client->disableReboot(); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address, 'operator', 'اپراتور'); + $created = $this->createResource($user, $address, $type, ['name' => 'اپراتور پرشیفت']); + $uuid = $created['data']['uuid']; + + $shifts = []; + foreach (range(0, 6) as $day) { + $shifts[$day] = [['start_minute' => 540, 'end_minute' => 1020]]; + } + $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, ['days' => $shifts]); + + $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]), + ]); + + $start = $this->midnight('next saturday'); + + $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ + 'type' => ResourceException::TYPE_LEAVE, + 'starts_at' => $start + 2 * 86400, + 'ends_at' => $start + 3 * 86400, + ]); + + $oneDay = $this->countQueries(fn () => $this->authJson( + 'GET', + "/api/v1/resource/$uuid/availability?from=$start&to=$start", + $user, + )); + self::assertSame(200, $this->responseCode()); + + $ninetyDays = $this->countQueries(fn () => $this->authJson( + 'GET', + sprintf('/api/v1/resource/%s/availability?from=%d&to=%d', $uuid, $start, $start + 89 * 86400), + $user, + )); + self::assertSame(200, $this->responseCode()); + + self::assertSame( + $oneDay, + $ninetyDays, + "محاسبهٔ یک روز $oneDay کوئری می‌خواهد و ۹۰ روز $ninetyDays — یعنی کوئری داخل حلقهٔ روز رفته", + ); + } +} diff --git a/tests/Resource/BackfillResourceTest.php b/tests/Resource/BackfillResourceTest.php index 1f031b02..5a7e3a58 100644 --- a/tests/Resource/BackfillResourceTest.php +++ b/tests/Resource/BackfillResourceTest.php @@ -34,6 +34,15 @@ class BackfillResourceTest extends ResourceTestCase return $tester->getDisplay(); } + private function runCalendarBackfill(): string + { + $application = new Application(static::$kernel); + $tester = new CommandTester($application->find('app:resource:calendar:backfill')); + $tester->execute(['--force' => true]); + + return $tester->getDisplay(); + } + private function pairOf(DoctorAddress $address): string { return $address->tenantEntityType() . ':' . $address->tenantEntityId(); @@ -207,6 +216,77 @@ class BackfillResourceTest extends ResourceTestCase self::assertFalse($this->resources()->findForSubject($reloaded)->isActive()); } + /** + * شیفت منابعِ پزشک از برنامهٔ هفتگی ساخته می‌شود، وگرنه منبعِ تازه‌ساخته هیچ + * ساعتی ندارد و لایهٔ منبع تا ورود دستی بی‌اثر می‌ماند. + */ + public function testCalendarBackfillDerivesShiftsFromTheWeeklySchedule(): void + { + [, $doctor, $address] = $this->doctorWithAddress(); + + $this->em->persist($this->newWeeklySchedule($doctor, [ + '0' => ['sessions' => [ + ['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], + ['active' => true, 'location_id' => $address->getId(), 'start_time' => '16:00', 'end_time' => '20:00', 'duration_per_patient' => 20], + ]], + '1' => ['sessions' => [ + ['active' => false, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], + ]], + ])); + $this->em->flush(); + + $this->runBackfill(true, $this->pairOf($address)); + $this->runCalendarBackfill(); + $this->em->clear(); + + $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); + $resource = $this->resources()->findForSubject($doctorEntity); + + $calendars = static::getContainer() + ->get(\App\Resource\Repository\ResourceCalendarRepository::class) + ->findForResource($resource); + + self::assertCount(2, $calendars, 'دو شیفت فعالِ شنبه؛ شیفت غیرفعال یکشنبه نمی‌آید'); + self::assertSame([0, 0], array_map(static fn ($c) => $c->getDayOfWeek(), $calendars)); + self::assertSame([540, 960], array_map(static fn ($c) => $c->getStartMinute(), $calendars)); + self::assertSame([0, 1], array_map(static fn ($c) => $c->getSequence(), $calendars)); + } + + /** منبعی که کاربر خودش تنظیم کرده دست نمی‌خورد — همین idempotent بودن را می‌دهد. */ + public function testCalendarBackfillLeavesAnAlreadyConfiguredResourceAlone(): void + { + [$user, $doctor, $address] = $this->doctorWithAddress(); + + $this->em->persist($this->newWeeklySchedule($doctor, [ + '0' => ['sessions' => [ + ['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], + ]], + ])); + $this->em->flush(); + + $this->runBackfill(true, $this->pairOf($address)); + $this->em->clear(); + + $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); + $resource = $this->resources()->findForSubject($doctorEntity); + + $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/calendar", $user, [ + 'days' => [3 => [['start_minute' => 600, 'end_minute' => 660]]], + ]); + self::assertSame(200, $this->responseCode()); + + $output = $this->runCalendarBackfill(); + self::assertStringContainsString('دست‌نخورده ماند', $output); + + $this->em->clear(); + $calendars = static::getContainer() + ->get(\App\Resource\Repository\ResourceCalendarRepository::class) + ->findForResource($this->resources()->findByUuid($resource->getUuid())); + + self::assertCount(1, $calendars); + self::assertSame(3, $calendars[0]->getDayOfWeek(), 'تنظیم دستی کاربر سرِ جایش است'); + } + /** حداکثر یک پل: قید در سازنده است، چون MariaDB `CHECK` چندستونی را اجرا نمی‌کند. */ public function testASecondBridgeIsRefused(): void { diff --git a/tests/Shared/TimeIntervalTest.php b/tests/Shared/TimeIntervalTest.php new file mode 100644 index 00000000..d152ab3d --- /dev/null +++ b/tests/Shared/TimeIntervalTest.php @@ -0,0 +1,127 @@ + $pairs */ + private function set(array $pairs): array + { + return array_map(static fn (array $p): TimeInterval => new TimeInterval($p[0], $p[1]), $pairs); + } + + /** @return list */ + private function flatten(array $intervals): array + { + return array_map(static fn (TimeInterval $i): array => [$i->start, $i->end], $intervals); + } + + public function testEndMustBeAfterStart(): void + { + $this->expectException(\InvalidArgumentException::class); + new TimeInterval(600, 600); + } + + public function testMergeJoinsOverlappingAndTouchingIntervals(): void + { + $merged = TimeInterval::mergeAll($this->set([[540, 780], [700, 900], [900, 960], [1200, 1260]])); + + self::assertSame([[540, 960], [1200, 1260]], $this->flatten($merged)); + } + + public function testMergeSortsUnorderedInput(): void + { + $merged = TimeInterval::mergeAll($this->set([[1200, 1260], [540, 600]])); + + self::assertSame([[540, 600], [1200, 1260]], $this->flatten($merged)); + } + + public function testIntersectKeepsOnlyTheCommonPart(): void + { + $result = TimeInterval::intersectAll( + $this->set([[540, 1020]]), + $this->set([[600, 720]]), + ); + + self::assertSame([[600, 720]], $this->flatten($result)); + } + + /** بازه‌های صرفاً چسبیده تقاطع ندارند — نیم‌باز بودن دقیقاً همین را می‌گوید. */ + public function testTouchingIntervalsDoNotIntersect(): void + { + $result = TimeInterval::intersectAll($this->set([[540, 780]]), $this->set([[780, 900]])); + + self::assertSame([], $result); + } + + public function testIntersectOfDisjointSetsIsEmpty(): void + { + $result = TimeInterval::intersectAll($this->set([[540, 600]]), $this->set([[1080, 1200]])); + + self::assertSame([], $result); + } + + /** بریدن از وسط، دو تکه می‌سازد و بقیهٔ روز سرِ جایش می‌ماند. */ + public function testSubtractSplitsAnIntervalInTwo(): void + { + $result = TimeInterval::subtractAll($this->set([[540, 1020]]), $this->set([[600, 720]])); + + self::assertSame([[540, 600], [720, 1020]], $this->flatten($result)); + } + + public function testSubtractTrimsTheEdges(): void + { + self::assertSame( + [[600, 1020]], + $this->flatten(TimeInterval::subtractAll($this->set([[540, 1020]]), $this->set([[500, 600]]))), + ); + + self::assertSame( + [[540, 900]], + $this->flatten(TimeInterval::subtractAll($this->set([[540, 1020]]), $this->set([[900, 1100]]))), + ); + } + + public function testSubtractCanEmptyTheSet(): void + { + self::assertSame([], TimeInterval::subtractAll($this->set([[540, 1020]]), $this->set([[400, 1200]]))); + } + + /** دو مسدودکنندهٔ هم‌پوشان اول اتحاد می‌شوند، بعد کسر — نه کسر پشت‌سرهم. */ + public function testOverlappingBlocksAreUnionedBeforeSubtracting(): void + { + $result = TimeInterval::subtractAll( + $this->set([[540, 1020]]), + $this->set([[600, 780], [720, 900]]), + ); + + self::assertSame([[540, 600], [900, 1020]], $this->flatten($result)); + } + + public function testSubtractingNothingReturnsTheInputMerged(): void + { + self::assertSame( + [[540, 1020]], + $this->flatten(TimeInterval::subtractAll($this->set([[540, 780], [780, 1020]]), [])), + ); + } + + /** تبدیل «دقیقه از نیمه‌شب» به timestamp — ضرب در ۶۰ باید صریح بماند. */ + public function testMinutesToAbsoluteMultipliesBySixty(): void + { + $absolute = (new TimeInterval(540, 1020))->minutesToAbsolute(1_000_000); + + self::assertSame(1_000_000 + 540 * 60, $absolute->start); + self::assertSame(1_000_000 + 1020 * 60, $absolute->end); + self::assertSame(480 * 60, $absolute->end - $absolute->start); + } +}