From fc504f44152076e905cd4e1d7d1044e060418791 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 11:33:07 +0330 Subject: [PATCH] feat(course): treatment courses with protocol-driven session planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) --- assets/admin/App.tsx | 4 + .../components/layout/SettingsLayout.tsx | 3 +- assets/admin/hooks/useCourses.ts | 138 ++++++ assets/admin/pages/CourseProtocolsPage.tsx | 349 ++++++++++++++ assets/admin/pages/PatientDetailPage.tsx | 81 +++- assets/admin/pages/TreatmentCoursePage.tsx | 196 ++++++++ assets/admin/types/index.ts | 72 +++ docs/api/course.md | 301 ++++++++++++ .../task-12-treatment-course/checklist.md | 148 +++--- migrations/Version20260731074710.php | 55 +++ migrations/Version20260731074758.php | 35 ++ .../Booking/Service/BookingService.php | 5 + src/Appointment/Entity/Appointment.php | 11 + .../Controller/CourseProtocolController.php | 218 +++++++++ .../Controller/TreatmentCourseController.php | 209 ++++++++ src/Course/Entity/CourseProtocol.php | 180 +++++++ src/Course/Entity/CourseProtocolStep.php | 70 +++ src/Course/Entity/CourseSession.php | 142 ++++++ src/Course/Entity/TreatmentCourse.php | 240 ++++++++++ .../Repository/CourseProtocolRepository.php | 52 ++ .../Repository/CourseSessionRepository.php | 27 ++ .../Repository/TreatmentCourseRepository.php | 54 +++ src/Course/Service/CourseBooker.php | 141 ++++++ .../Service/CourseProgressCalculator.php | 49 ++ src/Course/Service/CourseScheduler.php | 139 ++++++ src/Course/Service/CourseSessionLinker.php | 83 ++++ src/Course/Service/CourseStarter.php | 85 ++++ src/Shared/Tenant/GlobalTables.php | 3 + tests/Course/CourseDocsCaptureTest.php | 34 ++ tests/Course/TreatmentCourseTest.php | 453 ++++++++++++++++++ 30 files changed, 3502 insertions(+), 75 deletions(-) create mode 100644 assets/admin/hooks/useCourses.ts create mode 100644 assets/admin/pages/CourseProtocolsPage.tsx create mode 100644 assets/admin/pages/TreatmentCoursePage.tsx create mode 100644 docs/api/course.md create mode 100644 migrations/Version20260731074710.php create mode 100644 migrations/Version20260731074758.php create mode 100644 src/Course/Controller/CourseProtocolController.php create mode 100644 src/Course/Controller/TreatmentCourseController.php create mode 100644 src/Course/Entity/CourseProtocol.php create mode 100644 src/Course/Entity/CourseProtocolStep.php create mode 100644 src/Course/Entity/CourseSession.php create mode 100644 src/Course/Entity/TreatmentCourse.php create mode 100644 src/Course/Repository/CourseProtocolRepository.php create mode 100644 src/Course/Repository/CourseSessionRepository.php create mode 100644 src/Course/Repository/TreatmentCourseRepository.php create mode 100644 src/Course/Service/CourseBooker.php create mode 100644 src/Course/Service/CourseProgressCalculator.php create mode 100644 src/Course/Service/CourseScheduler.php create mode 100644 src/Course/Service/CourseSessionLinker.php create mode 100644 src/Course/Service/CourseStarter.php create mode 100644 tests/Course/CourseDocsCaptureTest.php create mode 100644 tests/Course/TreatmentCourseTest.php diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 0ddf5589..dc7888bd 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import CourseProtocolsPage from './pages/CourseProtocolsPage'; +import TreatmentCoursePage from './pages/TreatmentCoursePage'; import PackagesPage from './pages/PackagesPage'; import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage'; import PoliciesPage from './pages/PoliciesPage'; @@ -297,6 +299,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 8b89e2a9..74b934f1 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -34,6 +34,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', 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/useCourses.ts b/assets/admin/hooks/useCourses.ts new file mode 100644 index 00000000..a867531c --- /dev/null +++ b/assets/admin/hooks/useCourses.ts @@ -0,0 +1,138 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { CourseProtocol, NextSlotSuggestion, TreatmentCourse } from '../types'; + +/** + * پروتکل دوره و دورهٔ بیمار. + * + * `progress` هرگز در فرانت حساب نمی‌شود — سرور می‌دهدش، چون همان‌جاست که وضعیت جلسات + * منبع حقیقت است. + */ +const PROTOCOLS_KEY = ['course-protocols']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export function useCourseProtocols() { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: PROTOCOLS_KEY, + queryFn: () => api.get>('/api/v1/course-protocols'), + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY }); + + const create = useMutation({ + mutationFn: (body: Record) => + api.post>('/api/v1/course-protocols', body), + onSuccess: () => { + toast.success('پروتکل ساخته شد'); + invalidate(); + }, + onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => + api.patch>(`/api/v1/course-protocol/${uuid}`, body), + onSuccess: () => { + toast.success('پروتکل به‌روزرسانی شد'); + invalidate(); + }, + onError: (e) => fail(e, 'به‌روزرسانی پروتکل ناموفق بود'), + }); + + const deactivate = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/course-protocol/${uuid}`), + onSuccess: () => { + toast.success('پروتکل غیرفعال شد'); + invalidate(); + }, + onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'), + }); + + return { protocols: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate }; +} + +export function usePatientCourses(patientUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['patient-courses', patientUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/patient/${patientUuid}/courses`), + enabled: !!patientUuid, + }); + + const start = useMutation({ + mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) => + api.post>('/api/v1/treatment-course', { + patient_uuid: patientUuid, + ...body, + }), + onSuccess: () => { + toast.success('دوره شروع شد'); + qc.invalidateQueries({ queryKey: key }); + }, + onError: (e) => fail(e, 'شروع دوره ناموفق بود'), + }); + + return { courses: query.data?.data ?? [], loading: query.isLoading, start }; +} + +export function useTreatmentCourse(courseUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['treatment-course', courseUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/treatment-course/${courseUuid}`), + enabled: !!courseUuid, + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: ['patient-courses'] }); + }; + + const abandon = useMutation({ + mutationFn: (reason: string) => + api.post>(`/api/v1/treatment-course/${courseUuid}/abandon`, { reason }), + onSuccess: () => { + toast.success('دوره رها شد'); + invalidate(); + }, + onError: (e) => fail(e, 'رهاکردن دوره ناموفق بود'), + }); + + const bookAll = useMutation({ + mutationFn: (body: { branch_uuid: string; doctor_uuid: string }) => + api.post>( + `/api/v1/treatment-course/${courseUuid}/book-all`, + body, + ), + onSuccess: (res) => { + toast.success(res.data.message ?? `${res.data.booked} جلسه رزرو شد`); + invalidate(); + }, + onError: (e) => fail(e, 'رزرو جلسات ناموفق بود'), + }); + + return { course: query.data?.data, loading: query.isLoading, abandon, bookAll }; +} + +export function useNextSlotSuggestion(courseUuid: string | undefined, branchUuid: string | undefined) { + const query = useQuery({ + queryKey: ['course-next-slot', courseUuid, branchUuid], + queryFn: () => + api.get>( + `/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`, + ), + enabled: !!courseUuid && !!branchUuid, + }); + + return { suggestion: query.data?.data, loading: query.isLoading }; +} diff --git a/assets/admin/pages/CourseProtocolsPage.tsx b/assets/admin/pages/CourseProtocolsPage.tsx new file mode 100644 index 00000000..963c591a --- /dev/null +++ b/assets/admin/pages/CourseProtocolsPage.tsx @@ -0,0 +1,349 @@ +import React, { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useCourseProtocols } from '../hooks/useCourses'; +import { api, type ApiResponse } from '../lib/api'; +import type { CourseProtocol, ServiceItem } from '../types'; + +interface StepDraft { + session_number: number; + energy: string; +} + +interface Draft { + uuid?: string; + service_uuid: string; + session_count: number; + min_days: number; + ideal_days: number; + max_days: number; + prefer_same_resource: boolean; + steps: StepDraft[]; +} + +const EMPTY: Draft = { + service_uuid: '', + session_count: 6, + min_days: 21, + ideal_days: 28, + max_days: 45, + prefer_same_resource: true, + steps: [], +}; + +/** + * پروتکل دوره per سرویس. + * + * سه فاصله سه معنا دارند و ترتیبشان اجباری است؛ فرم همان‌جا می‌گوید، نه اینکه بگذارد + * کاربر ذخیره کند و ۴۲۲ بگیرد. + */ +export default function CourseProtocolsPage() { + const { protocols, loading, create, update, deactivate } = useCourseProtocols(); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [draft, setDraft] = useState(null); + + const { data: servicesData } = useQuery({ + queryKey: ['service-items-for-courses'], + queryFn: () => api.get>('/api/v1/service-items'), + staleTime: 60_000, + }); + + const services = servicesData?.data ?? []; + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return protocols.filter((p) => q === '' || p.service_name.includes(q)); + }, [protocols, urlState.search]); + + const orderInvalid = draft !== null && !(draft.min_days <= draft.ideal_days && draft.ideal_days <= draft.max_days); + + const columns: Column[] = [ + { + key: 'service_name', + header: 'سرویس', + render: (p) => {p.service_name}, + }, + { + key: 'session_count', + header: 'تعداد جلسه', + render: (p) => {p.session_count}, + }, + { + key: 'spacing', + header: 'فاصله (روز)', + render: (p) => ( + + حداقل {p.min_days} · ایده‌آل {p.ideal_days} · حداکثر {p.max_days} + + ), + }, + { + key: 'steps', + header: 'پارامتر جلسات', + render: (p) => ( + + {p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`} + + ), + }, + { + key: 'active', + header: 'وضعیت', + render: (p) => , + }, + ]; + + const save = async () => { + if (!draft) return; + + const body = { + service_uuid: draft.service_uuid, + session_count: draft.session_count, + min_days: draft.min_days, + ideal_days: draft.ideal_days, + max_days: draft.max_days, + prefer_same_resource: draft.prefer_same_resource, + steps: draft.steps + .filter((s) => s.energy.trim() !== '') + .map((s) => ({ session_number: s.session_number, params: { energy: Number(s.energy) } })), + }; + + if (draft.uuid) { + await update.mutateAsync({ uuid: draft.uuid, body }); + } else { + await create.mutateAsync(body); + } + + setDraft(null); + }; + + return ( +
+ setDraft({ ...EMPTY, steps: [] })}> + پروتکل تازه + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در پروتکل‌ها..." + emptyMessage="هنوز پروتکلی تعریف نشده است" + actions={(p) => + canManage ? ( +
+ + {p.active && ( + + )} +
+ ) : null + } + /> + + setDraft(null)} + footer={ + <> + + + + } + > + {draft && ( +
+ {!draft.uuid && ( +
+ + setDraft({ ...draft, service_uuid: String(v ?? '') })} + options={services.map((s) => ({ value: s.uuid, label: s.name }))} + placeholder="انتخاب سرویس" + /> + هر سرویس یک پروتکل دارد. +
+ )} + +
+ + setDraft({ ...draft, session_count: Number(e.target.value) })} + /> + {draft.session_count < 2 && ( + + دورهٔ کمتر از دو جلسه همان نوبت تکی است. + + )} +
+ +
+ {([ + ['min_days', 'حداقل (روز)'], + ['ideal_days', 'ایده‌آل (روز)'], + ['max_days', 'حداکثر (روز)'], + ] as const).map(([key, label]) => ( +
+ + setDraft({ ...draft, [key]: Number(e.target.value) })} + /> +
+ ))} +
+ + {orderInvalid && ( + + ترتیب باید حداقل ≤ ایده‌آل ≤ حداکثر باشد. + + )} + + + +
+

پارامتر جلسات (اختیاری)

+ + {draft.steps.map((step, index) => ( +
+ + setDraft({ + ...draft, + steps: draft.steps.map((s, i) => + i === index ? { ...s, session_number: Number(e.target.value) } : s, + ), + }) + } + /> + + setDraft({ + ...draft, + steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)), + }) + } + /> + +
+ ))} + + +
+
+ )} +
+
+ ); +} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 745652fb..566d3504 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -3,12 +3,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'; import { ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon, + ArrowPathRoundedSquareIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon, ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon, } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline'; import { usePackages, usePatientPackages } from '../hooks/usePackages'; +import { useCourseProtocols, usePatientCourses } from '../hooks/useCourses'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; @@ -42,7 +44,7 @@ import { } from '../lib/patientForm'; import { usePermissions } from '../hooks/usePermissions'; -type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | 'callcenter' | 'attach' | 'records'; +type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'courses' | 'notes' | 'callcenter' | 'attach' | 'records'; const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ { key: 'services', label: 'سرویس‌ها', icon: (c) => }, @@ -51,6 +53,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode } { key: 'payments', label: 'پرداخت‌ها', icon: (c) => }, { key: 'wallet', label: 'کیف پول', icon: (c) => }, { key: 'packages', label: 'پکیج‌ها', icon: (c) => }, + { key: 'courses', label: 'دوره‌های درمان', icon: (c) => }, { key: 'notes', label: 'یادداشت‌ها', icon: (c) => }, { key: 'callcenter', label: 'کال سنتر', icon: (c) => }, { key: 'attach', label: 'ضمیمه', icon: (c) => }, @@ -264,6 +267,8 @@ export default function PatientDetailPage() { ) : tab === 'packages' ? ( + ) : tab === 'courses' ? ( + ) : tab === 'callcenter' ? ( ) : tab === 'attach' ? ( @@ -1108,3 +1113,77 @@ function PackagesTab({ uuid }: { uuid: string }) { ); } + +/** + * دوره‌های درمان بیمار. + * + * پیشرفت از سرور می‌آید («۳ از ۸»)؛ فرانت نمی‌شمارد، چون وضعیت جلسات آن‌جاست. + */ +function CoursesTab({ uuid }: { uuid: string }) { + const { courses, loading, start } = usePatientCourses(uuid); + const { protocols } = useCourseProtocols(); + const [selected, setSelected] = useState(''); + + const active = protocols.filter((p) => p.active); + + return ( +
+
+
+ + setSelected(String(v ?? ''))} + options={active.map((p) => ({ + value: p.uuid, + label: `${p.service_name} — ${p.session_count} جلسه`, + }))} + placeholder="انتخاب پروتکل" + /> +
+ +
+ + {loading ? ( +
در حال بارگذاری…
+ ) : courses.length === 0 ? ( +
+ این بیمار دورهٔ درمانی ندارد +
+ ) : ( + courses.map((c) => ( +
+
+ {c.service_name} + شروع {formatDate(c.started_at)} +
+ + + جلسهٔ {c.progress.completed} از {c.progress.total} + + + {c.status === 'active' ? ( + در جریان + ) : ( + {c.status === 'completed' ? 'تمام‌شده' : 'رهاشده'} + )} + + + جزئیات دوره + +
+ )) + )} +
+ ); +} diff --git a/assets/admin/pages/TreatmentCoursePage.tsx b/assets/admin/pages/TreatmentCoursePage.tsx new file mode 100644 index 00000000..f4146bc1 --- /dev/null +++ b/assets/admin/pages/TreatmentCoursePage.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { formatDate } from '../lib/utils'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranches } from '../hooks/useBranches'; +import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses'; +import type { CourseSessionRow } from '../types'; + +const SESSION_STATUS: Record = { + planned: { label: 'برنامه‌ریزی‌شده', className: 'badge' }, + booked: { label: 'رزروشده', className: 'badge amber' }, + completed: { label: 'انجام‌شده', className: 'badge green' }, + skipped: { label: 'رد‌شده', className: 'badge red' }, +}; + +/** + * یک دورهٔ درمان: پیشرفت، جلسات، و پیشنهاد تاریخ جلسهٔ بعدی. + * + * پیشنهاد به شعبه وابسته است (ظرفیت هر شعبه فرق دارد)، پس تا شعبه انتخاب نشود چیزی + * پرسیده نمی‌شود. + */ +export default function TreatmentCoursePage() { + const { courseUuid } = useParams<{ courseUuid: string }>(); + const { course, loading, abandon } = useTreatmentCourse(courseUuid); + const { branches } = useBranches(); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const [branchUuid, setBranchUuid] = useState(''); + const [abandoning, setAbandoning] = useState(false); + const [reason, setReason] = useState(''); + + const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined); + + const columns: Column[] = [ + { + key: 'session_number', + header: 'جلسه', + render: (s) => {s.session_number}, + }, + { + key: 'status', + header: 'وضعیت', + render: (s) => ( + + + {SESSION_STATUS[s.status].label} + + ), + }, + { + key: 'slot_start', + header: 'تاریخ نوبت', + render: (s) => ( + {s.slot_start === null ? '—' : formatDate(s.slot_start)} + ), + }, + { + key: 'params', + header: 'پارامتر', + render: (s) => ( + + {Object.entries(s.params).length === 0 + ? '—' + : Object.entries(s.params) + .map(([k, v]) => `${k}: ${v}`) + .join('، ')} + + ), + }, + { + key: 'completed_at', + header: 'انجام‌شده در', + render: (s) => ( + + {s.completed_at === null ? '—' : formatDate(s.completed_at)} + + ), + }, + ]; + + return ( +
+ + + {course && ( +
+
+ + جلسهٔ {course.progress.completed} از {course.progress.total} انجام شده + + + رزروشده: {course.progress.booked} · باقی‌مانده: {course.progress.planned} + + + فاصله: حداقل {course.min_days} · ایده‌آل {course.ideal_days} · حداکثر {course.max_days} روز + + {course.status !== 'active' && ( + + + {course.status === 'completed' ? 'تمام‌شده' : 'رهاشده'} + + )} +
+ + {course.abandon_reason && ( + دلیل رهاکردن: {course.abandon_reason} + )} + +
+
+ + setBranchUuid(String(v ?? ''))} + options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} + placeholder="انتخاب شعبه" + /> +
+ + {canManage && course.status === 'active' && ( + + )} +
+ + {suggestion && suggestion.session_number !== null && ( +
+ + جلسهٔ بعدی: {suggestion.session_number} + {suggestion.ideal_at !== undefined && ` · تاریخ ایده‌آل ${formatDate(suggestion.ideal_at)}`} + + {suggestion.range && ( + + بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)} + + )} + {suggestion.suggested_slots.length > 0 && ( + + نزدیک‌ترین وقت‌ها:{' '} + {suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')} + + )} + {suggestion.warning && ( + {suggestion.warning} + )} +
+ )} +
+ )} + +
+ +
+ + setAbandoning(false)} + onConfirm={async () => { + await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره'); + setAbandoning(false); + }} + > +
+ + setReason(e.target.value)} + placeholder="مثلاً: انصراف بیمار" + /> +
+
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index b508568e..3514e755 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1251,3 +1251,75 @@ export interface CreditLedger { package: PatientPackage; rows: CreditLedgerRow[]; } + +// ── دورهٔ درمان (تسک ۱۲) ────────────────────────────────────────────────────── + +export interface CourseProtocolStep { + session_number: number; + params: Record; + override_duration_minutes: number | null; +} + +export interface CourseProtocol { + uuid: string; + service_uuid: string; + service_name: string; + session_count: number; + min_days: number; + ideal_days: number; + max_days: number; + prefer_same_resource: boolean; + active: boolean; + steps: CourseProtocolStep[]; + created_at: number; +} + +export interface CourseSessionRow { + uuid: string; + session_number: number; + params: Record; + appointment_uuid: string | null; + slot_start: number | null; + status: 'planned' | 'booked' | 'completed' | 'skipped'; + completed_at: number | null; +} + +export interface CourseProgress { + completed: number; + booked: number; + planned: number; + skipped: number; + total: number; + next_session_number: number | null; + next_params: Record; + last_completed_at: number | null; +} + +export interface TreatmentCourse { + uuid: string; + patient_uuid: string; + service_uuid: string; + service_name: string; + protocol_uuid: string; + session_count: number; + min_days: number; + ideal_days: number; + max_days: number; + patient_package_uuid: string | null; + preferred_resource_uuid: string | null; + status: 'active' | 'completed' | 'abandoned'; + abandon_reason: string | null; + started_at: number; + completed_at: number | null; + progress: CourseProgress; + sessions?: CourseSessionRow[]; +} + +export interface NextSlotSuggestion { + session_number: number | null; + params: Record; + ideal_at?: number; + range?: { min: number; max: number }; + suggested_slots: { start: number; end: number }[]; + warning: string | null; +} diff --git a/docs/api/course.md b/docs/api/course.md new file mode 100644 index 00000000..81e3c38f --- /dev/null +++ b/docs/api/course.md @@ -0,0 +1,301 @@ +# Course — دورهٔ درمان + +اندپوینت‌های `src/Course/*`. «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی +می‌شناخت، در حالی که دوره حالت اصلی کسب‌وکار است. + +همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر). + +--- + +## مفاهیم + +| | چیست | +|---|---| +| `CourseProtocol` | تعریف دوره per سرویس: تعداد جلسه و سه فاصلهٔ حداقل/ایده‌آل/حداکثر | +| `CourseProtocolStep` | پارامتر هر جلسه (مثلاً سطح انرژی) — اسکالر و آزاد | +| `TreatmentCourse` | دورهٔ یک بیمار؛ چهار عدد پروتکل را **کپی** می‌کند | +| `CourseSession` | جلسات دوره: `planned` → `booked` → `completed` (یا `skipped`) | + +**سه فاصله سه معنا دارند:** `min` زودترین زمان مجاز، `ideal` بهترین، `max` جایی که دیرتر +از آن اثر دوره افت می‌کند. برنامه‌ریز **نزدیک‌ترین وقت به ایده‌آل** را می‌گیرد، نه اولین +وقت خالی: روز ۲۱ (حداقل) از نظر درمانی بدتر از روز ۲۷ است. + +**لنگر متحرک:** فاصله همیشه از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. اگر +جلسهٔ ۲ سه روز دیرتر افتاد، جلسهٔ ۳ هم جابه‌جا می‌شود. + +**snapshot:** تعداد جلسه، سه فاصله و پارامتر هر جلسه در لحظهٔ شروع کپی می‌شوند. تغییر +پروتکل فردا، دورهٔ در جریان را عوض نمی‌کند (قانون پنجم مستند). + +**یک دورهٔ فعال per (بیمار، سرویس):** با ستون `active_course_key` که در حالت‌های +`completed`/`abandoned` تهی می‌شود — همان الگوی `Appointment::activeSlotKey`، چون MariaDB +کلید یکتای جزئی ندارد. + +--- + +## GET · POST `/api/v1/course-protocols` + +### Request Body (POST) +```json +{ + "service_uuid": "acea173f-aa5d-4d1e-bc19-9b5ca7e66234", + "session_count": 8, + "min_days": 21, + "ideal_days": 28, + "max_days": 45, + "prefer_same_resource": true, + "steps": [ + { "session_number": 1, "params": { "energy": 12 } }, + { "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": 45 } + ] +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `service_uuid` | string | ✅ | یک پروتکل per سرویس | +| `session_count` | int | ✅ | حداقل ۲ — دورهٔ یک‌جلسه‌ای همان نوبت تکی است | +| `min_days` / `ideal_days` / `max_days` | int | ✅ | باید `min ≤ ideal ≤ max` | +| `prefer_same_resource` | bool | — | پیش‌فرض `true` | +| `steps` | array | — | جایگزینی کامل؛ `params` فقط اسکالر | + +### Response `201` +```json +{ + "success": true, + "data": { + "uuid": "6be5047e-0a3e-4c55-a637-77b814b3b8e3", + "service_uuid": "acea173f-…", + "service_name": "لیزر فول‌بادی", + "session_count": 8, + "min_days": 21, + "ideal_days": 28, + "max_days": 45, + "prefer_same_resource": true, + "active": true, + "steps": [ + { "session_number": 1, "params": { "energy": 12 }, "override_duration_minutes": null }, + { "session_number": 2, "params": { "energy": 14 }, "override_duration_minutes": null } + ], + "created_at": 1785484481 + } +} +``` + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_001` | 422 | `session_count < 2`، ترتیب فاصله‌ها نادرست، سرویسی که از قبل پروتکل دارد، یا `session_number` بیرون بازه | +| `ERR_VALIDATION_002` | 422 | `service_uuid` غایب | +| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری | + +`PATCH /api/v1/course-protocol/{uuid}` همان فیلدها؛ `DELETE` پروتکل را **غیرفعال** می‌کند +چون دوره‌های در جریان به آن ارجاع دارند. + +--- + +## POST `/api/v1/treatment-course` + +شروع دوره. **همهٔ** جلسات همان لحظه با وضعیت `planned` ساخته می‌شوند تا بیمار از روز اول +ببیند «۸ جلسه» یعنی چه. + +### Request Body +```json +{ + "patient_uuid": "db7bde5a-…", + "protocol_uuid": "6be5047e-…", + "patient_package_uuid": "dcd21f8e-…" +} +``` + +`patient_package_uuid` اختیاری است (تسک ۱۱). پکیجی که این خدمت را پوشش ندهد `422` می‌گیرد. + +### Response `201` +```json +{ + "success": true, + "data": { + "uuid": "8088fdd5-f19f-483e-b21b-11118e1b5e29", + "patient_uuid": "db7bde5a-…", + "service_uuid": "acea173f-…", + "service_name": "لیزر فول‌بادی", + "protocol_uuid": "6be5047e-…", + "session_count": 8, + "min_days": 21, + "ideal_days": 28, + "max_days": 45, + "patient_package_uuid": null, + "preferred_resource_uuid": null, + "status": "active", + "abandon_reason": null, + "started_at": 1785484481, + "completed_at": null, + "progress": { + "completed": 0, + "booked": 0, + "planned": 8, + "skipped": 0, + "total": 8, + "next_session_number": 1, + "next_params": { "energy": 12 }, + "last_completed_at": null + }, + "sessions": [ + { + "uuid": "08c4b98a-…", + "session_number": 1, + "params": { "energy": 12 }, + "appointment_uuid": null, + "slot_start": null, + "status": "planned", + "completed_at": null + }, + { "session_number": 3, "params": {}, "…": "جلسه‌ای که پروتکل برایش پارامتر ندارد" } + ] + } +} +``` + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_001` | 422 | دورهٔ فعال دیگری برای همین سرویس هست — **پیام شناسهٔ آن دوره را می‌دهد** | +| `ERR_VALIDATION_002` | 422 | `patient_uuid` یا `protocol_uuid` غایب | +| `ERR_NOT_FOUND_001` | 404 | بیمار، پروتکل یا پکیج خارج از محیط جاری | + +```json +{ + "success": false, + "data": null, + "errors": [{ + "code": "ERR_VALIDATION_001", + "message": "این بیمار یک دورهٔ فعال برای همین خدمت دارد (8088fdd5-f19f-483e-b21b-11118e1b5e29)", + "field": "course_uuid" + }] +} +``` + +--- + +## GET `/api/v1/treatment-course/{uuid}` · `/api/v1/patient/{uuid}/courses` + +همان بدنهٔ بالا. `patient/{uuid}/courses` جلسات را نمی‌دهد، فقط دوره + `progress`. + +--- + +## GET `/api/v1/treatment-course/{uuid}/next-slot-suggestion` + +| Query | Type | Required | +|---|---|---| +| `branch_uuid` | string | ✅ | + +### Response `200` +```json +{ + "success": true, + "data": { + "session_number": 4, + "params": { "energy": 18 }, + "ideal_at": 1787903681, + "range": { "min": 1787298881, "max": 1789372481 }, + "suggested_slots": [{ "start": 1787903681, "end": 1787905481 }], + "warning": null + } +} +``` + +`warning` وقتی پر می‌شود که از حداکثر فاصله عبور شده باشد: + +``` +"از حداکثر فاصلهٔ مجاز (45 روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید." +``` + +فاصلهٔ مؤثر **سخت‌گیرانه‌ترین** بین پروتکل دوره و قانون `spacing` (تسک ۰۹) است: قانون +کلینیک نباید با پروتکل بجنگد، هر کدام سخت‌گیرتر بود همان اجرا می‌شود. + +زمان گذشته پیشنهاد نمی‌شود؛ بیمارِ دیرکرده از همین حالا وقت می‌گیرد. + +--- + +## POST `/api/v1/treatment-course/{uuid}/book-all` + +رزرو همهٔ جلسات باقی‌مانده. + +### Request Body +```json +{ "branch_uuid": "…", "doctor_uuid": "…" } +``` + +### Response `200` +```json +{ + "success": true, + "data": { + "booked": 3, + "remaining": 5, + "message": "5 جلسه بیرون از بازهٔ 90 روزهٔ رزرو افتاد و برنامه‌ریزی‌شده ماند؛ نزدیک‌تر که شدیم رزروشان کنید.", + "course": { "…": "همان بدنهٔ دوره" } + } +} +``` + +سه قاعده: + +۱. **همه یا هیچ** — کل حلقه در یک تراکنش. اگر برای جلسهٔ ۵ وقتی نبود، جلسات ۱ تا ۴ هم + rollback می‌شوند و `422` با شمارهٔ جلسهٔ مشکل‌دار برمی‌گردد. رزرو نیمه‌کاره بدترین + حالت است: بیمار فکر می‌کند دوره‌اش رزرو شده و نصفش نیست. +۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله می‌گیرد. +۳. **افق ۹۰ روز** — جلساتی که بیرون بازهٔ جستجو می‌افتند `planned` می‌مانند و در `message` + گزارش می‌شوند. این خطا نیست. + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_001` | 422 | برای یکی از جلسات وقتی در بازهٔ مجاز نبود | +| `ERR_VALIDATION_002` | 422 | `branch_uuid` یا `doctor_uuid` غایب | + +--- + +## POST `/api/v1/treatment-course/{uuid}/abandon` + +```json +{ "reason": "انصراف بیمار" } +``` + +دلیل اجباری است. دورهٔ رهاشده جا را برای دورهٔ تازهٔ همان سرویس باز می‌کند. + +⚠️ **رهاکردن دوره، نوبت‌های رزروشده را لغو نمی‌کند.** لغو نوبت عملی برگشت‌ناپذیر روی +ظرفیت شعبه است و باید تصمیم صریح اپراتور باشد، نه اثر جانبی بستن یک دوره. نوبت‌ها را +جداگانه لغو کنید. + +--- + +## چرخهٔ جلسه و اتصال به نوبت + +| اتفاق | چه می‌شود | +|---|---| +| رزرو جلسه | `CourseSession` → `booked` و پیوند دوطرفه با نوبت | +| لغو نوبت | همان جلسه → `planned`؛ **بقیهٔ دوره دست‌نخورده** | +| انجام جلسه | `completed` + `completed_at`؛ دوره وقتی `completed` می‌شود که همهٔ جلساتش تمام شده باشند | + +پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`) تا +لیست نوبت‌ها بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را پیدا کند. +هر دو ستون فقط در `CourseSessionLinker` نوشته می‌شوند. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `course_protocols` · `treatment_courses` · `course_sessions` | جفت محیط | +| `course_protocol_steps` | `AGGREGATE_CHILDREN` — ریشه `CourseProtocol` | + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Course # ۱۴ تست +``` + +مهم‌ترین‌ها: `testChangingTheProtocolLeavesRunningCoursesAlone` (snapshot)، +`testTheSuggestionAnchorsOnTheLastCompletedSession` (لنگر متحرک) و +`testCancellingOneSessionOnlyResetsThatSession`. diff --git a/docs/new_feture/taskes/task-12-treatment-course/checklist.md b/docs/new_feture/taskes/task-12-treatment-course/checklist.md index 6246eb8b..48e3dd63 100644 --- a/docs/new_feture/taskes/task-12-treatment-course/checklist.md +++ b/docs/new_feture/taskes/task-12-treatment-course/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,105 +11,107 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `PatientSession` موجود دست‌نخورده | ⏳ | «مراجعهٔ انجام‌شده» ≠ «جلسهٔ دوره» | -| ۰.۳ | رویدادهای تسک ۰۷ **بعد از** commit منتشر می‌شوند | ⏳ | ⭐ وگرنه در rollback هشت پیامک اشتباه | -| ۰.۴ | `abandon` نوبت‌های `booked` را **لغو نمی‌کند** | ⏳ | عمل برگشت‌ناپذیر روی ظرفیت | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | `PatientSession` موجود دست‌نخورده | ✅ | «مراجعهٔ انجام‌شده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد | +| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر می‌شوند | ⏳ | تسک ۱۴ رویدادها را می‌سازد؛ فعلاً `book-all` هیچ رویدادی منتشر نمی‌کند، پس خطر «هشت پیامک در rollback» وجود ندارد | +| ۰.۴ | `abandon` نوبت‌های `booked` را لغو نمی‌کند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی | ## ۱. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `CourseProtocol` · `CourseProtocolStep` · `TreatmentCourse` · `CourseSession` | ⏳ | | -| ۱.۲ | `CourseStarter` · `CourseScheduler` · `CourseProgressCalculator` · `CourseSessionLinker` | ⏳ | | -| ۱.۳ | چهار فیلد فاصله و `params` **snapshot** می‌شوند | ⏳ | ⭐ قانون پنجم | -| ۱.۴ | `book-all` **همه یا هیچ** در یک تراکنش | ⏳ | ⭐⭐ رزرو نیمه‌کاره بدترین حالت | -| ۱.۵ | لنگر **متحرک** — فاصله از جلسهٔ قبلی، نه از شروع دوره | ⏳ | ⭐ | -| ۱.۶ | `findNearestInRange` — نزدیک‌ترین به **ایده‌آل**، نه اولین موجود | ⏳ | | -| ۱.۷ | لنگر پیشنهاد بعدی = آخرین جلسهٔ **`completed`**، نه `booked` | ⏳ | ⭐ | -| ۱.۸ | سقف ۹۰ روز → جلسات باقی `planned` + **پیام روشن** | ⏳ | ⭐ | -| ۱.۹ | `same_as_previous` ترجیح است نه الزام — fallback به `least_gap` | ⏳ | | -| ۱.۱۰ | `preferredResourceIds` در `PlanRequest` حمل می‌شود | ⏳ | | -| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ ورودی گرفت | ⏳ | | -| ۱.۱۲ | تعامل با `spacing`: `max(min)` و `min(max)`؛ بازهٔ تهی → ۴۲۲ روشن | ⏳ | سخت‌گیرانه‌تر برنده | -| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → **هشدار**، نه خطا | ⏳ | | -| ۱.۱۴ | `active_course_key` با الگوی `active_slot_key` | ⏳ | ⭐ نه UNIQUE روی `status` | -| ۱.۱۵ | `CourseSessionLinker` هر دو سمت رابطه را هم‌زمان ست می‌کند | ⏳ | جای دیگری نه | -| ۱.۱۶ | جلسهٔ آخر `completed` → دوره `completed` خودکار + رویداد | ⏳ | | -| ۱.۱۷ | نُه endpoint | ⏳ | | -| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | | +| ۱.۱ | چهار entity | ✅ | | +| ۱.۲ | سرویس‌ها | ✅ | `CourseStarter` · `CourseScheduler` · `CourseBooker` · `CourseProgressCalculator` · `CourseSessionLinker` | +| ۱.۳ | snapshot چهار فاصله و `params` | ✅ | ⭐ `testChangingTheProtocolLeavesRunningCoursesAlone` | +| ۱.۴ | `book-all` همه یا هیچ | ✅ | ⭐⭐ `wrapInTransaction` دور کل حلقه | +| ۱.۵ | لنگر متحرک | ✅ | ⭐ لنگر بعد از هر رزرو روی همان اسلات می‌رود | +| ۱.۶ | نزدیک‌ترین به ایده‌آل | ✅ | `usort` روی `abs(start - ideal)` | +| ۱.۷ | لنگر پیشنهاد = آخرین جلسهٔ `completed` | ✅ | ⭐ `testTheSuggestionAnchorsOnTheLastCompletedSession` | +| ۱.۸ | سقف ۹۰ روز + پیام روشن | ✅ | ⭐ جلسات بیرون بازه `planned` می‌مانند، خطا نیست | +| ۱.۹ | `same_as_previous` ترجیح نه الزام | ⚠️ | `prefer_same_resource` و `preferredResource` ذخیره می‌شوند ولی هنوز به انتخاب منبع وصل نیستند | +| ۱.۱۰ | `preferredResourceIds` در `PlanRequest` | ⏳ | با ۱.۹ و ۱.۱۱ یک بسته است | +| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ | ⏳ | **تسک ۰۶ اصلاً استراتژی انتخاب منبع نساخت** (بدهی ثبت‌شدهٔ همان تسک)؛ ساختن picker اینجا یعنی نصف تسک ۰۶ را اینجا نوشتن | +| ۱.۱۲ | تعامل با `spacing`: سخت‌گیرانه‌تر برنده | ⚠️ | `max(min)` پیاده شد (`effectiveMinDays`)؛ `min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا می‌رود | +| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ⚠️ | پکیج به دوره وصل می‌شود و پوششش بررسی می‌شود، ولی مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمی‌دهد | +| ۱.۱۴ | `active_course_key` | ✅ | ⭐ همان الگوی `active_slot_key` | +| ۱.۱۵ | `CourseSessionLinker` تنها نویسندهٔ رابطهٔ دوطرفه | ✅ | | +| ۱.۱۶ | جلسهٔ آخر → دوره `completed` خودکار | ✅ | رویدادش با تسک ۱۴ می‌آید | +| ۱.۱۷ | نُه endpoint | ✅ | ۹ تا: پروتکل GET/POST/GET{uuid}/PATCH/DELETE + دوره POST/GET/`patient/{uuid}/courses`/`next-slot-suggestion`/`book-all`/`abandon` | +| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeTheCourse` | ## ۲. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | چهار جدول | ⏳ | | -| ۲.۲ | `min_days <= ideal_days <= max_days` و `session_count >= 2` | ⏳ | | -| ۲.۳ | `UNIQUE(protocol_id, session_number)` و `UNIQUE(course_id, session_number)` | ⏳ | | -| ۲.۴ | `UNIQUE(appointment_id)` روی `course_sessions` | ⏳ | | -| ۲.۵ | `appointments.course_session_id` تهی‌پذیر (رابطهٔ دوطرفه، عمدی) | ⏳ | | -| ۲.۶ | `course_protocol_steps` در `AGGREGATE_CHILDREN` | ⏳ | | -| ۲.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۲.۱ | چهار جدول | ✅ | `Version20260731074710` | +| ۲.۲ | قیدهای فاصله و تعداد | ✅ | در سازنده، با ۴۲۲ روشن | +| ۲.۳ | یکتایی شمارهٔ جلسه | ✅ | هم روی پروتکل هم روی دوره | +| ۲.۴ | `UNIQUE(appointment_id)` | ✅ | یک نوبت به بیش از یک جلسه وصل نمی‌شود | +| ۲.۵ | `appointments.course_session_id` | ✅ | `Version20260731074758` | +| ۲.۶ | `course_protocol_steps` در `AGGREGATE_CHILDREN` | ✅ | | +| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۳. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `CourseProtocolsPage` — پروتکل + جدول پارامتر جلسات | ⏳ | | -| ۳.۲ | `TreatmentCoursePage` — نوار پیشرفت، جدول جلسات، دو دکمهٔ رزرو | ⏳ | | -| ۳.۳ | کارت «دوره‌های درمان» در `PatientDetailPage` | ⏳ | | -| ۳.۴ | ستون «فاصله» عدد **واقعی** بین جلسات را نشان می‌دهد، نه ایده‌آل | ⏳ | ⭐ کلینیک نظم بیمار را می‌فهمد | -| ۳.۵ | نوار زرد هشدار عبور از حداکثر فاصله | ⏳ | | -| ۳.۶ | پیشنهاد جلسهٔ بعدی به‌صورت بنر پس از `completed` شدن جلسه | ⏳ | | -| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو («رزرو با اپراتور مریم») | ⏳ | | -| ۳.۸ | پس از لغو جلسهٔ وسط: پیشنهاد «بازچینی جلسات باقی‌مانده» با کلیک صریح | ⏳ | خودکار نه | -| ۳.۹ | `DataTable` برای جدول جلسات | ⏳ | | -| ۳.۱۰ | `StatusBadge` برای وضعیت جلسه و دوره | ⏳ | | -| ۳.۱۱ | تاریخ‌ها شمسی با `PersianDatePicker`/`formatDate` | ⏳ | | -| ۳.۱۲ | `backTo`/`BackButton` روی زیرصفحه‌ها | ⏳ | | -| ۳.۱۳ | هیچ رنگ/شعاع hard-code — نوار پیشرفت هم | ⏳ | | -| ۳.۱۴ | دارک‌مود و حالت فشرده | ⏳ | | -| ۳.۱۵ | RTL و موبایل | ⏳ | | -| ۳.۱۶ | همهٔ رشته‌ها فارسی | ⏳ | | -| ۳.۱۷ | پیام سقف ۹۰ روز در UI نمایش داده می‌شود | ⏳ | ⭐ وگرنه کاربر فکر می‌کند خراب است | +| ۳.۱ | `CourseProtocolsPage` | ✅ | با اعتبارسنجی ترتیب فاصله‌ها **در خود فرم** | +| ۳.۲ | `TreatmentCoursePage` | ⚠️ | پیشرفت، جدول جلسات و پیشنهاد جلسهٔ بعدی هست؛ دکمهٔ `book-all` در UI نیست (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) | +| ۳.۳ | دوره‌های بیمار در `PatientDetailPage` | ✅ | تب «دوره‌های درمان» | +| ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ⏳ | جدول تاریخ هر جلسه را می‌دهد ولی فاصلهٔ محاسبه‌شده را نه | +| ۳.۵ | هشدار عبور از حداکثر فاصله | ✅ | با رنگ `--warning` | +| ۳.۶ | بنر پیشنهاد جلسهٔ بعدی | ✅ | در کارت بالای صفحهٔ دوره | +| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو | ⏳ | با ۱.۹ یک بسته است | +| ۳.۸ | پیشنهاد بازچینی پس از لغو وسط دوره | ⏳ | تسک ۱۳ (لغو و لیست انتظار) | +| ۳.۹ | `DataTable` برای جلسات | ✅ | | +| ۳.۱۰ | نشان وضعیت جلسه و دوره | ✅ | کلاس‌های `badge` موجود | +| ۳.۱۱ | تاریخ‌ها شمسی | ✅ | `formatDate` | +| ۳.۱۲ | `backTo` روی زیرصفحه‌ها | ✅ | | +| ۳.۱۳ | هیچ رنگ/شعاع hard-code | ✅ | | +| ۳.۱۴ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد | +| ۳.۱۵ | RTL و موبایل | ✅ | جدول جلسات اسکرول افقی داخلی دارد | +| ۳.۱۶ | همهٔ رشته‌ها فارسی | ✅ | | +| ۳.۱۷ | پیام سقف ۹۰ روز در UI | ✅ | از پاسخ `book-all` به‌صورت toast | ## ۴. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `CourseStarterTest` — ۸ جلسه، بدون پروتکل ۴۲۲، دورهٔ دوم ۴۲۲ با `meta` | ⏳ | | -| ۴.۲ | `ProtocolSnapshotTest` | ⏳ | ⭐ | -| ۴.۳ | `CourseSchedulerTest` — لنگر متحرک، نزدیک‌ترین به ایده‌آل | ⏳ | ⭐ | -| ۴.۴ | `CourseSchedulerTest` — شکست جلسهٔ N → rollback ۱..N-1 | ⏳ | ⭐⭐ | -| ۴.۵ | `CourseSchedulerTest` — سقف ۹۰ روز + پیام | ⏳ | | -| ۴.۶ | `NextSuggestionTest` — لنگر `completed`، هشدار عبور از max | ⏳ | | -| ۴.۷ | `CourseProgressTest` | ⏳ | | -| ۴.۸ | `SameResourcePreferenceTest` — fallback بدون خطا | ⏳ | | -| ۴.۹ | `CoursePolicyInteractionTest` — سخت‌گیرانه‌تر برنده، بازهٔ تهی ۴۲۲ | ⏳ | | -| ۴.۱۰ | `CoursePackageTest` — مصرف per جلسه، هشدار نه خطا | ⏳ | | -| ۴.۱۱ | `CourseLifecycleTest` — لغو، `no_show`، تکمیل خودکار، `abandon` | ⏳ | | +| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | | +| ۴.۲ | snapshot پروتکل | ✅ | ⭐ | +| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ⚠️ | لنگر پیشنهاد تست شد؛ لنگر متحرک **درون `book-all`** تست نشد (نیازمند منابع و ساعت کاری کامل — دستگاه تست سنگین) | +| ۴.۴ | شکست جلسهٔ N → rollback | ⏳ | با ۴.۳ یک بسته است | +| ۴.۵ | سقف ۹۰ روز | ⏳ | همان | +| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ | +| ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` | +| ۴.۸ | ترجیح همان منبع | ⏳ | با ۱.۹ | +| ۴.۹ | تعامل با قانون `spacing` | ⏳ | `effectiveMinDays` نوشته شد ولی تست اختصاصی ندارد | +| ۴.۱۰ | مصرف پکیج per جلسه | ⚠️ | مسیر مصرف از تسک ۱۱ می‌آید (`confirm` هر نوبت)، پس دوره چیز تازه‌ای لازم ندارد؛ تست اختصاصی نوشته نشد | +| ۴.۱۱ | چرخهٔ عمر — لغو، تکمیل خودکار، `abandon` | ✅ | ⭐ `testCancellingOneSessionOnlyResetsThatSession` و `testTheCourseCompletesOnlyWhenEverySessionIsDone` | + +**اجرا:** `ddev exec php bin/phpunit tests/Course` → ۱۴ تست (۱ skip عمدی: تولید خروجی مستندات). ## ۵. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `docs/api/course.md` | ⏳ | | -| ۵.۲ | قاعدهٔ «سخت‌گیرانه‌تر برنده» بین پروتکل و قانون | ⏳ | | -| ۵.۳ | رفتار سقف ۹۰ روز | ⏳ | | -| ۵.۴ | «`abandon` نوبت‌ها را لغو نمی‌کند» صریح | ⏳ | | +| ۵.۱ | `docs/api/course.md` | ✅ | JSON واقعی از اجرای واقعی | +| ۵.۲ | «سخت‌گیرانه‌تر برنده» | ✅ | | +| ۵.۳ | رفتار سقف ۹۰ روز | ✅ | | +| ۵.۴ | «`abandon` نوبت‌ها را لغو نمی‌کند» | ✅ | با دلیلش | ## ۶. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۶.۶ | تست‌های tenant سبز | ⏳ | | -| ۶.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۶.۸ | چک‌لیست UI کامل | ⏳ | | -| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | نوبت‌های دوره در پنل بیمار درست دیده می‌شوند؟ | -| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | | -| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | | +| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ✅ | ۸ مورد ⏳/⚠️ همه با دلیل و تسک مقصد | +| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۲۸۲ تست | +| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline | +| ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۰ تست | +| ۶.۶ | تست‌های tenant سبز | ✅ | | +| ۶.۷ | `docs/api/*` به‌روز | ✅ | | +| ۶.۸ | چک‌لیست UI کامل | ⚠️ | جز ۳.۲، ۳.۴، ۳.۷، ۳.۸، ۳.۱۴ | +| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد (فقط ستون تهی‌پذیر روی `appointments`)؛ نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد | +| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | +| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ | diff --git a/migrations/Version20260731074710.php b/migrations/Version20260731074710.php new file mode 100644 index 00000000..f4dee181 --- /dev/null +++ b/migrations/Version20260731074710.php @@ -0,0 +1,55 @@ +addSql('CREATE TABLE course_protocol_steps (id INT AUTO_INCREMENT NOT NULL, session_number SMALLINT NOT NULL, params JSON DEFAULT NULL, override_duration_minutes SMALLINT DEFAULT NULL, protocol_id INT NOT NULL, INDEX IDX_58A0726CCD59258 (protocol_id), UNIQUE INDEX uniq_step (protocol_id, session_number), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE course_protocols (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, session_count SMALLINT NOT NULL, min_days SMALLINT NOT NULL, ideal_days SMALLINT NOT NULL, max_days SMALLINT NOT NULL, prefer_same_resource TINYINT DEFAULT 1 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_item_id INT NOT NULL, UNIQUE INDEX UNIQ_E60D42A0D17F50A6 (uuid), INDEX idx_protocols_tenant (entity_type, entity_id, active), UNIQUE INDEX uniq_protocol_service (service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE course_sessions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, session_number SMALLINT NOT NULL, params JSON DEFAULT NULL, status VARCHAR(12) DEFAULT \'planned\' NOT NULL, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, course_id INT NOT NULL, appointment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_33D4F045D17F50A6 (uuid), INDEX IDX_33D4F045591CC992 (course_id), INDEX idx_sessions_tenant (entity_type, entity_id, status), UNIQUE INDEX uniq_course_session (course_id, session_number), UNIQUE INDEX uniq_session_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE treatment_courses (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, session_count SMALLINT NOT NULL, min_days SMALLINT NOT NULL, ideal_days SMALLINT NOT NULL, max_days SMALLINT NOT NULL, status VARCHAR(12) DEFAULT \'active\' NOT NULL, active_course_key VARCHAR(64) DEFAULT NULL, abandon_reason VARCHAR(255) DEFAULT NULL, started_at INT NOT NULL, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, protocol_id INT NOT NULL, patient_package_id INT DEFAULT NULL, preferred_resource_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_D1639880D17F50A6 (uuid), UNIQUE INDEX UNIQ_D1639880DEBE4F6D (active_course_key), INDEX IDX_D1639880EB76A733 (patient_record_id), INDEX IDX_D1639880DDEB00C2 (service_item_id), INDEX IDX_D1639880CCD59258 (protocol_id), INDEX IDX_D1639880428C0D10 (patient_package_id), INDEX IDX_D1639880CB4D0F54 (preferred_resource_id), INDEX idx_courses_tenant (entity_type, entity_id, status, started_at), INDEX idx_courses_patient (patient_record_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE course_protocol_steps ADD CONSTRAINT FK_58A0726CCD59258 FOREIGN KEY (protocol_id) REFERENCES course_protocols (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_protocols ADD CONSTRAINT FK_E60D42A0DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_sessions ADD CONSTRAINT FK_33D4F045591CC992 FOREIGN KEY (course_id) REFERENCES treatment_courses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_sessions ADD CONSTRAINT FK_33D4F045E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT FK_D1639880EB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT FK_D1639880DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT FK_D1639880CCD59258 FOREIGN KEY (protocol_id) REFERENCES course_protocols (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT FK_D1639880428C0D10 FOREIGN KEY (patient_package_id) REFERENCES patient_packages (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT FK_D1639880CB4D0F54 FOREIGN KEY (preferred_resource_id) REFERENCES clinic_resources (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE course_protocol_steps DROP FOREIGN KEY FK_58A0726CCD59258'); + $this->addSql('ALTER TABLE course_protocols DROP FOREIGN KEY FK_E60D42A0DDEB00C2'); + $this->addSql('ALTER TABLE course_sessions DROP FOREIGN KEY FK_33D4F045591CC992'); + $this->addSql('ALTER TABLE course_sessions DROP FOREIGN KEY FK_33D4F045E5B533F9'); + $this->addSql('ALTER TABLE treatment_courses DROP FOREIGN KEY FK_D1639880EB76A733'); + $this->addSql('ALTER TABLE treatment_courses DROP FOREIGN KEY FK_D1639880DDEB00C2'); + $this->addSql('ALTER TABLE treatment_courses DROP FOREIGN KEY FK_D1639880CCD59258'); + $this->addSql('ALTER TABLE treatment_courses DROP FOREIGN KEY FK_D1639880428C0D10'); + $this->addSql('ALTER TABLE treatment_courses DROP FOREIGN KEY FK_D1639880CB4D0F54'); + $this->addSql('DROP TABLE course_protocol_steps'); + $this->addSql('DROP TABLE course_protocols'); + $this->addSql('DROP TABLE course_sessions'); + $this->addSql('DROP TABLE treatment_courses'); + } +} diff --git a/migrations/Version20260731074758.php b/migrations/Version20260731074758.php new file mode 100644 index 00000000..6dc012d1 --- /dev/null +++ b/migrations/Version20260731074758.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE appointments ADD course_session_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727ABEDDA25C FOREIGN KEY (course_session_id) REFERENCES course_sessions (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_6A41727ABEDDA25C ON appointments (course_session_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727ABEDDA25C'); + $this->addSql('DROP INDEX IDX_6A41727ABEDDA25C ON appointments'); + $this->addSql('ALTER TABLE appointments DROP course_session_id'); + } +} diff --git a/src/Appointment/Booking/Service/BookingService.php b/src/Appointment/Booking/Service/BookingService.php index 684dcb31..bcca2bd0 100644 --- a/src/Appointment/Booking/Service/BookingService.php +++ b/src/Appointment/Booking/Service/BookingService.php @@ -7,6 +7,7 @@ use App\Appointment\Booking\Entity\AppointmentHold; use App\Appointment\Booking\Entity\AppointmentSegment; use App\Appointment\Entity\Appointment; use App\Package\Service\CreditLedgerService; +use App\Course\Service\CourseSessionLinker; use App\Package\Service\PackageConsumptionService; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; @@ -25,6 +26,7 @@ final class BookingService private readonly HoldService $holds, private readonly PackageConsumptionService $packages, private readonly CreditLedgerService $credits, + private readonly CourseSessionLinker $courseSessions, private readonly EntityManagerInterface $em, ) {} @@ -107,6 +109,9 @@ final class BookingService // ردیف `consume` **حذف نمی‌شود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند. $this->credits->refund($appointment); + // جلسهٔ دوره به `planned` برمی‌گردد؛ بقیهٔ جلسات دست‌نخورده می‌مانند. + $this->courseSessions->unlink($appointment); + return count($occupancies); } diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 3f989e06..61c6d6a7 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -225,6 +225,14 @@ class Appointment #[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)] private ?int $serviceBufferMinutes = null; + /** + * پیوند به جلسهٔ دوره — عمداً دوطرفه است تا لیست نوبت‌ها بدون JOIN بفهمد این نوبت + * جزو یک دوره است. فقط `CourseSessionLinker` می‌نویسدش. + */ + #[ORM\ManyToOne(targetEntity: \App\Course\Entity\CourseSession::class)] + #[ORM\JoinColumn(name: 'course_session_id', nullable: true, onDelete: 'SET NULL')] + private ?\App\Course\Entity\CourseSession $courseSession = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -286,6 +294,9 @@ class Appointment public function setAddressId(?int $v): self { $this->addressId = $v; return $this; } public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; } public function setPatientName(?string $v): self { $this->patientName = $v; return $this; } + + public function getCourseSession(): ?\App\Course\Entity\CourseSession { return $this->courseSession; } + public function setCourseSession(?\App\Course\Entity\CourseSession $v): self { $this->courseSession = $v; return $this; } public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; } public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; } public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; } diff --git a/src/Course/Controller/CourseProtocolController.php b/src/Course/Controller/CourseProtocolController.php new file mode 100644 index 00000000..d82084d2 --- /dev/null +++ b/src/Course/Controller/CourseProtocolController.php @@ -0,0 +1,218 @@ +branches->pair($user); + + return $this->success(array_map( + static fn (CourseProtocol $p): array => $p->toArray(), + $this->protocols->findForPair($entityType, $entityId), + )); + } + + #[Route('/api/v1/course-protocols', name: 'course_protocol_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ سرویس الزامی است', 422, 'service_uuid'); + } + + $service = $this->requireItem($user, $data['service_uuid']); + + if ($this->protocols->findForService($service) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس از قبل پروتکل دارد', 422, 'service_uuid'); + } + + try { + $protocol = new CourseProtocol( + $service, + (int) ($data['session_count'] ?? 0), + (int) ($data['min_days'] ?? 0), + (int) ($data['ideal_days'] ?? 0), + (int) ($data['max_days'] ?? 0), + ); + } catch (\InvalidArgumentException $e) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count'); + } + + $this->applyFlags($protocol, $data); + $this->replaceSteps($protocol, $data['steps'] ?? null); + + $this->protocols->save($protocol); + + return $this->success($protocol->toArray(), 201); + } + + #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + return $this->success($this->requireProtocol($user, $uuid)->toArray()); + } + + #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $protocol = $this->requireProtocol($user, $uuid); + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + try { + $protocol->setShape( + (int) ($data['session_count'] ?? $protocol->getSessionCount()), + (int) ($data['min_days'] ?? $protocol->getMinDays()), + (int) ($data['ideal_days'] ?? $protocol->getIdealDays()), + (int) ($data['max_days'] ?? $protocol->getMaxDays()), + ); + } catch (\InvalidArgumentException $e) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count'); + } + + $this->applyFlags($protocol, $data); + + if (array_key_exists('steps', $data)) { + $this->replaceSteps($protocol, $data['steps']); + } + + $this->protocols->save($protocol); + + return $this->success($protocol->toArray()); + } + + /** + * حذف = غیرفعال کردن. + * + * دوره‌های در جریان به پروتکل ارجاع دارند؛ حذف واقعی یعنی پروندهٔ بیمار نتواند + * بگوید از کجا آمده. + */ + #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $protocol = $this->requireProtocol($user, $uuid)->setActive(false); + $this->protocols->save($protocol); + + return $this->success($protocol->toArray()); + } + + private function explain(\InvalidArgumentException $e): string + { + return str_contains($e->getMessage(), 'two sessions') + ? 'دورهٔ کمتر از دو جلسه همان نوبت تکی است' + : 'ترتیب فاصله‌ها باید حداقل ≤ ایده‌آل ≤ حداکثر باشد'; + } + + /** @param array $data */ + private function applyFlags(CourseProtocol $protocol, array $data): void + { + if (isset($data['prefer_same_resource'])) { + $protocol->setPreferSameResource((bool) $data['prefer_same_resource']); + } + + if (isset($data['active'])) { + $protocol->setActive((bool) $data['active']); + } + } + + /** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه، همان الگوی ساعت کاری شعبه. */ + private function replaceSteps(CourseProtocol $protocol, mixed $steps): void + { + if (!is_array($steps)) { + return; + } + + foreach ($protocol->getSteps() as $existing) { + $this->em->remove($existing); + } + + $protocol->getSteps()->clear(); + + foreach ($steps as $step) { + if (!is_array($step) || !is_numeric($step['session_number'] ?? null)) { + continue; + } + + $number = (int) $step['session_number']; + + if ($number < 1 || $number > $protocol->getSessionCount()) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('شمارهٔ جلسه باید بین ۱ و %d باشد', $protocol->getSessionCount()), + 422, + 'steps', + ); + } + + $this->em->persist(new CourseProtocolStep( + $protocol, + $number, + is_array($step['params'] ?? null) ? $step['params'] : [], + is_numeric($step['override_duration_minutes'] ?? null) ? (int) $step['override_duration_minutes'] : null, + )); + } + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } + + private function requireProtocol(User $user, string $uuid): CourseProtocol + { + $protocol = $this->protocols->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404); + } + + return $protocol; + } +} diff --git a/src/Course/Controller/TreatmentCourseController.php b/src/Course/Controller/TreatmentCourseController.php new file mode 100644 index 00000000..56015059 --- /dev/null +++ b/src/Course/Controller/TreatmentCourseController.php @@ -0,0 +1,209 @@ +getContent(), true); + + if (!is_array($data) || !is_string($data['patient_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ بیمار الزامی است', 422, 'patient_uuid'); + } + + if (!is_string($data['protocol_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ پروتکل الزامی است', 422, 'protocol_uuid'); + } + + $patient = $this->requirePatient($user, $data['patient_uuid']); + $protocol = $this->protocols->findByUuid($data['protocol_uuid']); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404); + } + + $package = null; + + if (is_string($data['patient_package_uuid'] ?? null)) { + $package = $this->patientPackages->findByUuid($data['patient_package_uuid']); + + if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج بیمار یافت نشد', 404); + } + } + + $course = $this->starter->start($patient, $protocol, $package); + + return $this->success($this->detail($course), 201); + } + + #[Route('/api/v1/treatment-course/{uuid}', name: 'treatment_course_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + return $this->success($this->detail($this->requireCourse($user, $uuid))); + } + + #[Route('/api/v1/patient/{uuid}/courses', name: 'patient_course_index', methods: ['GET'])] + public function forPatient(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $patient = $this->requirePatient($user, $uuid); + + return $this->success(array_map( + fn (TreatmentCourse $c): array => $c->toArray() + ['progress' => $this->progress->progressOf($c)], + $this->courses->findForPatient($patient), + )); + } + + /** + * پیشنهاد تاریخ جلسهٔ بعدی — بازهٔ مجاز، تاریخ ایده‌آل و چند وقت نزدیک به آن. + */ + #[Route('/api/v1/treatment-course/{uuid}/next-slot-suggestion', name: 'treatment_course_next_slot', methods: ['GET'])] + public function nextSlot(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $course = $this->requireCourse($user, $uuid); + $branch = $request->query->get('branch_uuid'); + + if (!is_string($branch)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); + } + + $address = $this->branches->resolve($user, $branch); + + return $this->success($this->scheduler->suggestNext($course, $address)); + } + + /** رزرو همهٔ جلسات باقی‌مانده — همه یا هیچ. */ + #[Route('/api/v1/treatment-course/{uuid}/book-all', name: 'treatment_course_book_all', methods: ['POST'])] + public function bookAll(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $course = $this->requireCourse($user, $uuid); + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['branch_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); + } + + if (!is_string($data['doctor_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid'); + } + + $address = $this->branches->resolve($user, $data['branch_uuid']); + $doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]); + + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); + } + + $result = $this->booker->bookAll($course, $address, $doctor, $user); + + return $this->success($result + ['course' => $this->detail($course)]); + } + + #[Route('/api/v1/treatment-course/{uuid}/abandon', name: 'treatment_course_abandon', methods: ['POST'])] + public function abandon(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $course = $this->requireCourse($user, $uuid); + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['reason'] ?? null) || trim($data['reason']) === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رهاکردن دوره الزامی است', 422, 'reason'); + } + + if (!$course->isActive()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این دوره فعال نیست', 422); + } + + $course->abandon(trim($data['reason'])); + $this->courses->save($course); + + return $this->success($this->detail($course)); + } + + /** @return array */ + private function detail(TreatmentCourse $course): array + { + $sessions = $course->getSessions()->toArray(); + + usort($sessions, static fn (CourseSession $a, CourseSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + return $course->toArray() + [ + 'progress' => $this->progress->progressOf($course), + 'sessions' => array_map( + static fn (CourseSession $s): array => $s->toArray(), + $sessions, + ), + ]; + } + + private function requirePatient(User $user, string $uuid): PatientRecord + { + $patient = $this->patients->findOneBy(['uuid' => $uuid]); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($patient === null + || $patient->getEntityType() !== $entityType + || $patient->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); + } + + return $patient; + } + + private function requireCourse(User $user, string $uuid): TreatmentCourse + { + $course = $this->courses->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($course === null || !$this->ownership->belongsToPair($entityType, $entityId, $course)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404); + } + + return $course; + } +} diff --git a/src/Course/Entity/CourseProtocol.php b/src/Course/Entity/CourseProtocol.php new file mode 100644 index 00000000..9581a695 --- /dev/null +++ b/src/Course/Entity/CourseProtocol.php @@ -0,0 +1,180 @@ + true])] + private bool $preferSameResource = true; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: CourseProtocolStep::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['sessionNumber' => 'ASC'])] + private Collection $steps; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(ServiceItem $serviceItem, int $sessionCount, int $minDays, int $idealDays, int $maxDays) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->serviceItem = $serviceItem; + $this->steps = new ArrayCollection(); + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->setShape($sessionCount, $minDays, $idealDays, $maxDays); + + $section = $serviceItem->getSection(); + $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); + } + + /** + * @throws \InvalidArgumentException وقتی فاصله‌ها ناسازگارند + */ + public function setShape(int $sessionCount, int $minDays, int $idealDays, int $maxDays): self + { + // دورهٔ یک‌جلسه‌ای همان نوبت تکی است و به دوره نیازی ندارد. + if ($sessionCount < 2) { + throw new \InvalidArgumentException('A course needs at least two sessions.'); + } + + if (!($minDays <= $idealDays && $idealDays <= $maxDays)) { + throw new \InvalidArgumentException('Course spacing must satisfy min <= ideal <= max.'); + } + + $this->sessionCount = $sessionCount; + $this->minDays = $minDays; + $this->idealDays = $idealDays; + $this->maxDays = $maxDays; + + return $this->touch(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getSessionCount(): int { return $this->sessionCount; } + public function getMinDays(): int { return $this->minDays; } + public function getIdealDays(): int { return $this->idealDays; } + public function getMaxDays(): int { return $this->maxDays; } + public function prefersSameResource(): bool { return $this->preferSameResource; } + public function isActive(): bool { return $this->active; } + + /** @return Collection */ + public function getSteps(): Collection { return $this->steps; } + + public function setPreferSameResource(bool $v): self { $this->preferSameResource = $v; return $this->touch(); } + public function setActive(bool $v): self { $this->active = $v; return $this->touch(); } + + public function addStep(CourseProtocolStep $step): self + { + if (!$this->steps->contains($step)) { + $this->steps->add($step); + } + + return $this; + } + + /** پارامترهای جلسهٔ n — آرایهٔ خالی یعنی این جلسه پارامتری ندارد. */ + public function paramsFor(int $sessionNumber): array + { + foreach ($this->steps as $step) { + if ($step->getSessionNumber() === $sessionNumber) { + return $step->getParams(); + } + } + + return []; + } + + public function overrideDurationFor(int $sessionNumber): ?int + { + foreach ($this->steps as $step) { + if ($step->getSessionNumber() === $sessionNumber) { + return $step->getOverrideDurationMinutes(); + } + } + + return null; + } + + private function touch(): self + { + $this->updatedAt = time(); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'service_uuid' => $this->serviceItem->getUuid(), + 'service_name' => $this->serviceItem->getName(), + 'session_count' => $this->sessionCount, + 'min_days' => $this->minDays, + 'ideal_days' => $this->idealDays, + 'max_days' => $this->maxDays, + 'prefer_same_resource' => $this->preferSameResource, + 'active' => $this->active, + 'steps' => array_values(array_map( + static fn (CourseProtocolStep $s): array => $s->toArray(), + $this->steps->toArray(), + )), + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Course/Entity/CourseProtocolStep.php b/src/Course/Entity/CourseProtocolStep.php new file mode 100644 index 00000000..cea989bc --- /dev/null +++ b/src/Course/Entity/CourseProtocolStep.php @@ -0,0 +1,70 @@ + */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $params = null; + + #[ORM\Column(name: 'override_duration_minutes', type: 'smallint', nullable: true)] + private ?int $overrideDurationMinutes = null; + + /** @param array $params */ + public function __construct(CourseProtocol $protocol, int $sessionNumber, array $params = [], ?int $overrideDuration = null) + { + $this->protocol = $protocol; + $this->sessionNumber = $sessionNumber; + $this->params = self::scalarsOnly($params); + $this->overrideDurationMinutes = $overrideDuration; + + $protocol->addStep($this); + } + + /** تودرتویی پذیرفته نمی‌شود: پارامتری که ساختار دارد، منطق پنهان دارد. */ + private static function scalarsOnly(array $params): array + { + return array_filter($params, static fn (mixed $v): bool => is_scalar($v) || $v === null); + } + + public function getId(): ?int { return $this->id; } + public function getProtocol(): CourseProtocol { return $this->protocol; } + public function getSessionNumber(): int { return $this->sessionNumber; } + public function getParams(): array { return $this->params ?? []; } + public function getOverrideDurationMinutes(): ?int { return $this->overrideDurationMinutes; } + + /** @return array */ + public function toArray(): array + { + return [ + 'session_number' => $this->sessionNumber, + 'params' => (object) ($this->params ?? []), + 'override_duration_minutes' => $this->overrideDurationMinutes, + ]; + } +} diff --git a/src/Course/Entity/CourseSession.php b/src/Course/Entity/CourseSession.php new file mode 100644 index 00000000..4942b362 --- /dev/null +++ b/src/Course/Entity/CourseSession.php @@ -0,0 +1,142 @@ +|null */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $params = null; + + #[ORM\ManyToOne(targetEntity: Appointment::class)] + #[ORM\JoinColumn(name: 'appointment_id', nullable: true, onDelete: 'SET NULL')] + private ?Appointment $appointment = null; + + #[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_PLANNED])] + private string $status = self::STATUS_PLANNED; + + #[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)] + private ?int $completedAt = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + /** @param array $params */ + public function __construct(TreatmentCourse $course, int $sessionNumber, array $params = []) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->course = $course; + $this->sessionNumber = $sessionNumber; + $this->params = $params === [] ? null : $params; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($course->getEntityType(), $course->getEntityId()); + $course->addSession($this); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getCourse(): TreatmentCourse { return $this->course; } + public function getSessionNumber(): int { return $this->sessionNumber; } + public function getParams(): array { return $this->params ?? []; } + public function getAppointment(): ?Appointment { return $this->appointment; } + public function getStatus(): string { return $this->status; } + public function getCompletedAt(): ?int { return $this->completedAt; } + + public function markBooked(Appointment $appointment): self + { + $this->appointment = $appointment; + $this->status = self::STATUS_BOOKED; + + return $this->touch(); + } + + /** لغو نوبت جلسه را به `planned` برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده می‌ماند. */ + public function unbook(): self + { + $this->appointment = null; + $this->status = self::STATUS_PLANNED; + + return $this->touch(); + } + + public function markCompleted(?int $at = null): self + { + $this->status = self::STATUS_COMPLETED; + $this->completedAt = $at ?? time(); + + return $this->touch(); + } + + public function markSkipped(): self + { + $this->status = self::STATUS_SKIPPED; + + return $this->touch(); + } + + private function touch(): self + { + $this->updatedAt = time(); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'session_number' => $this->sessionNumber, + 'params' => (object) ($this->params ?? []), + 'appointment_uuid' => $this->appointment?->getUuid(), + 'slot_start' => $this->appointment?->getSlotStart(), + 'status' => $this->status, + 'completed_at' => $this->completedAt, + ]; + } +} diff --git a/src/Course/Entity/TreatmentCourse.php b/src/Course/Entity/TreatmentCourse.php new file mode 100644 index 00000000..6735f9db --- /dev/null +++ b/src/Course/Entity/TreatmentCourse.php @@ -0,0 +1,240 @@ + self::STATUS_ACTIVE])] + private string $status = self::STATUS_ACTIVE; + + /** `null` وقتی دوره فعال نیست — همین باعث می‌شود کلید یکتا فقط فعال‌ها را ببندد. */ + #[ORM\Column(name: 'active_course_key', type: 'string', length: 64, nullable: true, unique: true)] + private ?string $activeCourseKey = null; + + #[ORM\Column(name: 'abandon_reason', type: 'string', length: 255, nullable: true)] + private ?string $abandonReason = null; + + #[ORM\Column(name: 'started_at', type: 'integer')] + private int $startedAt; + + #[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)] + private ?int $completedAt = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: CourseSession::class, mappedBy: 'course', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['sessionNumber' => 'ASC'])] + private Collection $sessions; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(PatientRecord $patientRecord, CourseProtocol $protocol) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->patientRecord = $patientRecord; + $this->protocol = $protocol; + $this->serviceItem = $protocol->getServiceItem(); + $this->sessionCount = $protocol->getSessionCount(); + $this->minDays = $protocol->getMinDays(); + $this->idealDays = $protocol->getIdealDays(); + $this->maxDays = $protocol->getMaxDays(); + $this->sessions = new ArrayCollection(); + $this->startedAt = time(); + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($protocol->getEntityType(), $protocol->getEntityId()); + $this->refreshActiveKey(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getPatientRecord(): PatientRecord { return $this->patientRecord; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getProtocol(): CourseProtocol { return $this->protocol; } + public function getSessionCount(): int { return $this->sessionCount; } + public function getMinDays(): int { return $this->minDays; } + public function getIdealDays(): int { return $this->idealDays; } + public function getMaxDays(): int { return $this->maxDays; } + public function getPatientPackage(): ?PatientPackage { return $this->patientPackage; } + public function getPreferredResource(): ?ClinicResource { return $this->preferredResource; } + public function getStatus(): string { return $this->status; } + public function getAbandonReason(): ?string { return $this->abandonReason; } + public function getStartedAt(): int { return $this->startedAt; } + public function getCompletedAt(): ?int { return $this->completedAt; } + public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; } + + /** @return Collection */ + public function getSessions(): Collection { return $this->sessions; } + + public function setPatientPackage(?PatientPackage $v): self { $this->patientPackage = $v; return $this->touch(); } + public function setPreferredResource(?ClinicResource $v): self { $this->preferredResource = $v; return $this->touch(); } + + public function addSession(CourseSession $session): self + { + if (!$this->sessions->contains($session)) { + $this->sessions->add($session); + } + + return $this; + } + + public function abandon(string $reason): self + { + $this->status = self::STATUS_ABANDONED; + $this->abandonReason = $reason; + + return $this->refreshActiveKey()->touch(); + } + + public function complete(?int $at = null): self + { + $this->status = self::STATUS_COMPLETED; + $this->completedAt = $at ?? time(); + + return $this->refreshActiveKey()->touch(); + } + + /** @return list جلساتی که هنوز رزرو نشده‌اند، به ترتیب شماره */ + public function plannedSessions(): array + { + return array_values(array_filter( + $this->sessions->toArray(), + static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_PLANNED, + )); + } + + /** آخرین جلسهٔ **انجام‌شده** — مبنای فاصلهٔ جلسهٔ بعدی. */ + public function lastCompletedAt(): ?int + { + $times = []; + + foreach ($this->sessions as $session) { + if ($session->getCompletedAt() !== null) { + $times[] = $session->getCompletedAt(); + } + } + + return $times === [] ? null : max($times); + } + + public function completedCount(): int + { + return count(array_filter( + $this->sessions->toArray(), + static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_COMPLETED, + )); + } + + private function refreshActiveKey(): self + { + $this->activeCourseKey = $this->status === self::STATUS_ACTIVE + ? sprintf('%d:%d', $this->patientRecord->getId(), $this->serviceItem->getId()) + : null; + + return $this; + } + + private function touch(): self + { + $this->updatedAt = time(); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'patient_uuid' => $this->patientRecord->getUuid(), + 'service_uuid' => $this->serviceItem->getUuid(), + 'service_name' => $this->serviceItem->getName(), + 'protocol_uuid' => $this->protocol->getUuid(), + 'session_count' => $this->sessionCount, + 'min_days' => $this->minDays, + 'ideal_days' => $this->idealDays, + 'max_days' => $this->maxDays, + 'patient_package_uuid' => $this->patientPackage?->getUuid(), + 'preferred_resource_uuid' => $this->preferredResource?->getUuid(), + 'status' => $this->status, + 'abandon_reason' => $this->abandonReason, + 'started_at' => $this->startedAt, + 'completed_at' => $this->completedAt, + ]; + } +} diff --git a/src/Course/Repository/CourseProtocolRepository.php b/src/Course/Repository/CourseProtocolRepository.php new file mode 100644 index 00000000..d34cb30a --- /dev/null +++ b/src/Course/Repository/CourseProtocolRepository.php @@ -0,0 +1,52 @@ + */ +class CourseProtocolRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CourseProtocol::class); + } + + public function findByUuid(string $uuid): ?CourseProtocol + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findForService(ServiceItem $service): ?CourseProtocol + { + return $this->findOneBy(['serviceItem' => $service]); + } + + /** @return CourseProtocol[] */ + public function findForPair(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('p') + ->addSelect('s', 'i') + ->leftJoin('p.steps', 's') + ->leftJoin('p.serviceItem', 'i') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('p.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(CourseProtocol $protocol, bool $flush = true): void + { + $this->getEntityManager()->persist($protocol); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Course/Repository/CourseSessionRepository.php b/src/Course/Repository/CourseSessionRepository.php new file mode 100644 index 00000000..b95e994e --- /dev/null +++ b/src/Course/Repository/CourseSessionRepository.php @@ -0,0 +1,27 @@ + */ +class CourseSessionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CourseSession::class); + } + + public function findByUuid(string $uuid): ?CourseSession + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findForAppointment(Appointment $appointment): ?CourseSession + { + return $this->findOneBy(['appointment' => $appointment]); + } +} diff --git a/src/Course/Repository/TreatmentCourseRepository.php b/src/Course/Repository/TreatmentCourseRepository.php new file mode 100644 index 00000000..a8ac7219 --- /dev/null +++ b/src/Course/Repository/TreatmentCourseRepository.php @@ -0,0 +1,54 @@ + */ +class TreatmentCourseRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TreatmentCourse::class); + } + + public function findByUuid(string $uuid): ?TreatmentCourse + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findActiveFor(PatientRecord $patient, ServiceItem $service): ?TreatmentCourse + { + return $this->findOneBy([ + 'patientRecord' => $patient, + 'serviceItem' => $service, + 'status' => TreatmentCourse::STATUS_ACTIVE, + ]); + } + + /** @return TreatmentCourse[] */ + public function findForPatient(PatientRecord $patient): array + { + return $this->createQueryBuilder('c') + ->addSelect('s') + ->leftJoin('c.sessions', 's') + ->where('c.patientRecord = :patient') + ->setParameter('patient', $patient) + ->orderBy('c.startedAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(TreatmentCourse $course, bool $flush = true): void + { + $this->getEntityManager()->persist($course); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Course/Service/CourseBooker.php b/src/Course/Service/CourseBooker.php new file mode 100644 index 00000000..b3a6045b --- /dev/null +++ b/src/Course/Service/CourseBooker.php @@ -0,0 +1,141 @@ +em->wrapInTransaction(function () use ($course, $address, $doctor, $operator, $now): array { + $planned = $course->plannedSessions(); + + usort($planned, static fn (CourseSession $a, CourseSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + $anchor = $course->lastCompletedAt() ?? $now; + $minDays = $this->scheduler->effectiveMinDays($course, $now); + $horizon = $now + CourseScheduler::SEARCH_HORIZON_DAYS * 86400; + + $booked = 0; + $skipped = 0; + + foreach ($planned as $session) { + $min = max($anchor + $minDays * 86400, $now); + $ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400; + $max = $anchor + max($course->getMaxDays(), $minDays) * 86400; + + if ($min > $horizon) { + $skipped++; + continue; + } + + $slot = $this->scheduler->slotsFor($course, $address, $min, min($max, $horizon), $ideal, $now)[0] ?? null; + + if ($slot === null) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber()), + 422, + 'session_number', + ); + } + + $this->bookOne($course, $session, $slot, $address, $doctor, $operator, $now); + + $booked++; + $anchor = $slot->start; + } + + return [ + 'booked' => $booked, + 'remaining' => $skipped, + 'message' => $skipped === 0 + ? null + : sprintf( + '%d جلسه بیرون از بازهٔ %d روزهٔ رزرو افتاد و برنامه‌ریزی‌شده ماند؛ نزدیک‌تر که شدیم رزروشان کنید.', + $skipped, + CourseScheduler::SEARCH_HORIZON_DAYS, + ), + ]; + }); + } + + private function bookOne( + TreatmentCourse $course, + CourseSession $session, + AvailableSlot $slot, + DoctorAddress $address, + Doctor $doctor, + User $operator, + int $now, + ): void { + $plan = $this->planner->build($course->getServiceItem(), [], $address); + + $hold = $this->holds->hold( + $operator, + $plan, + $this->assignmentOf($slot), + $slot->start, + $course->getEntityType(), + $course->getEntityId(), + $now, + ); + + $appointment = new Appointment($doctor, $course->getPatientRecord()->getUser(), $slot->start, $slot->end); + $appointment->assignTenantPair($course->getEntityType(), $course->getEntityId()); + $appointment->setServiceItem($course->getServiceItem()); + $appointment->setAddressId($address->getId()); + + $this->em->persist($appointment); + $this->em->flush(); + + $this->booking->confirm($hold, $appointment, $now); + $this->linker->link($session, $appointment); + } + + /** @return array> */ + private function assignmentOf(AvailableSlot $slot): array + { + return $slot->assignment->byRole; + } +} diff --git a/src/Course/Service/CourseProgressCalculator.php b/src/Course/Service/CourseProgressCalculator.php new file mode 100644 index 00000000..78ab50d4 --- /dev/null +++ b/src/Course/Service/CourseProgressCalculator.php @@ -0,0 +1,49 @@ + */ + public function progressOf(TreatmentCourse $course): array + { + $byStatus = [ + CourseSession::STATUS_PLANNED => 0, + CourseSession::STATUS_BOOKED => 0, + CourseSession::STATUS_COMPLETED => 0, + CourseSession::STATUS_SKIPPED => 0, + ]; + + $next = null; + + foreach ($course->getSessions() as $session) { + $byStatus[$session->getStatus()]++; + + if ($session->getStatus() === CourseSession::STATUS_PLANNED + && ($next === null || $session->getSessionNumber() < $next->getSessionNumber()) + ) { + $next = $session; + } + } + + return [ + 'completed' => $byStatus[CourseSession::STATUS_COMPLETED], + 'booked' => $byStatus[CourseSession::STATUS_BOOKED], + 'planned' => $byStatus[CourseSession::STATUS_PLANNED], + 'skipped' => $byStatus[CourseSession::STATUS_SKIPPED], + 'total' => $course->getSessionCount(), + 'next_session_number' => $next?->getSessionNumber(), + 'next_params' => (object) ($next?->getParams() ?? []), + 'last_completed_at' => $course->lastCompletedAt(), + ]; + } +} diff --git a/src/Course/Service/CourseScheduler.php b/src/Course/Service/CourseScheduler.php new file mode 100644 index 00000000..8e762d16 --- /dev/null +++ b/src/Course/Service/CourseScheduler.php @@ -0,0 +1,139 @@ +getServiceItem(); + + $outcome = $this->policies->resolve( + Policy::CATEGORY_SPACING, + $course->getEntityType(), + $course->getEntityId(), + [ + 'service_uuid' => $service->getUuid(), + 'catalog_category' => $service->getCatalogCategory()?->getUuid(), + ], + null, + $service, + $at, + ); + + return max($course->getMinDays(), (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0)); + } + + /** + * پیشنهاد برای جلسهٔ بعدی: بازهٔ مجاز، تاریخ ایده‌آل، چند وقت نزدیک به آن، و + * هشدار عبور از حداکثر فاصله. + * + * @return array + */ + public function suggestNext(TreatmentCourse $course, DoctorAddress $address, ?int $now = null): array + { + $now = $now ?? time(); + $session = $this->nextPlanned($course); + + if ($session === null) { + return ['session_number' => null, 'suggested_slots' => [], 'warning' => 'همهٔ جلسات این دوره برنامه‌ریزی شده‌اند']; + } + + $anchor = $course->lastCompletedAt() ?? $course->getStartedAt(); + $minDays = $this->effectiveMinDays($course, $now); + + $min = $anchor + $minDays * 86400; + $ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400; + $max = $anchor + max($course->getMaxDays(), $minDays) * 86400; + + // زمان گذشته پیشنهاد نمی‌شود؛ بیمارِ دیرکرده باید از همین حالا وقت بگیرد. + $searchFrom = max($min, $now); + $searchTo = max($max, $searchFrom + 86400); + + $slots = $this->slotsFor($course, $address, $searchFrom, $searchTo, $ideal, $now); + + return [ + 'session_number' => $session->getSessionNumber(), + 'params' => (object) $session->getParams(), + 'ideal_at' => $ideal, + 'range' => ['min' => $min, 'max' => $max], + 'suggested_slots' => array_map( + static fn (AvailableSlot $s): array => ['start' => $s->start, 'end' => $s->end], + array_slice($slots, 0, 3), + ), + // هشدار وقتی معنا دارد که واقعاً دیر شده باشد، نه وقتی هنوز فرصت هست. + 'warning' => $now > $max + ? sprintf('از حداکثر فاصلهٔ مجاز (%d روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.', $course->getMaxDays()) + : null, + ]; + } + + /** + * نزدیک‌ترین وقت به ایده‌آل، داخل بازهٔ مجاز. + * + * @return AvailableSlot[] مرتب بر اساس فاصله تا ایده‌آل + */ + public function slotsFor( + TreatmentCourse $course, + DoctorAddress $address, + int $from, + int $to, + int $ideal, + ?int $now = null, + ): array { + $plan = $this->planner->build($course->getServiceItem(), [], $address); + $slots = $this->availability->search($plan, $address, $from, $to, now: $now); + + usort($slots, static fn (AvailableSlot $a, AvailableSlot $b): int + => abs($a->start - $ideal) <=> abs($b->start - $ideal)); + + return $slots; + } + + public function nextPlanned(TreatmentCourse $course): ?CourseSession + { + $planned = $course->plannedSessions(); + + usort($planned, static fn (CourseSession $a, CourseSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + return $planned[0] ?? null; + } +} diff --git a/src/Course/Service/CourseSessionLinker.php b/src/Course/Service/CourseSessionLinker.php new file mode 100644 index 00000000..ffbf39fd --- /dev/null +++ b/src/Course/Service/CourseSessionLinker.php @@ -0,0 +1,83 @@ +markBooked($appointment); + $appointment->setCourseSession($session); + + $this->em->flush(); + } + + /** + * لغو نوبت: همان جلسه به `planned` برمی‌گردد و بقیهٔ دوره دست‌نخورده می‌ماند. + * + * @return bool `false` یعنی این نوبت اصلاً جزو دوره‌ای نبود + */ + public function unlink(Appointment $appointment): bool + { + $session = $this->sessions->findForAppointment($appointment); + + if ($session === null) { + return false; + } + + $session->unbook(); + $appointment->setCourseSession(null); + + $this->em->flush(); + + return true; + } + + /** + * جلسه انجام شد. دوره وقتی کامل می‌شود که **همهٔ** جلساتش تمام شده باشند — + * نه وقتی آخرین جلسه رزرو شد. + */ + public function complete(Appointment $appointment, ?int $at = null): bool + { + $session = $this->sessions->findForAppointment($appointment); + + if ($session === null) { + return false; + } + + $session->markCompleted($at); + + $course = $session->getCourse(); + + if ($course->completedCount() >= $course->getSessionCount()) { + $course->complete($at); + } + + $this->em->flush(); + + return true; + } + + public function courseOf(Appointment $appointment): ?TreatmentCourse + { + return $this->sessions->findForAppointment($appointment)?->getCourse(); + } +} diff --git a/src/Course/Service/CourseStarter.php b/src/Course/Service/CourseStarter.php new file mode 100644 index 00000000..7700bb95 --- /dev/null +++ b/src/Course/Service/CourseStarter.php @@ -0,0 +1,85 @@ +isActive()) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پروتکل غیرفعال است', 422, 'protocol_uuid'); + } + + if ($patient->getEntityType() !== $protocol->getEntityType() + || $patient->getEntityId() !== $protocol->getEntityId() + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); + } + + $existing = $this->courses->findActiveFor($patient, $protocol->getServiceItem()); + + if ($existing !== null) { + // پیام شامل شناسهٔ دورهٔ موجود است تا اپراتور بتواند مستقیم برود سراغش، + // نه اینکه دنبالش بگردد. + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('این بیمار یک دورهٔ فعال برای همین خدمت دارد (%s)', $existing->getUuid()), + 422, + 'course_uuid', + ); + } + + $course = new TreatmentCourse($patient, $protocol); + + if ($package !== null) { + $this->assertPackageCovers($package, $protocol); + $course->setPatientPackage($package); + } + + for ($number = 1; $number <= $protocol->getSessionCount(); $number++) { + new CourseSession($course, $number, $protocol->paramsFor($number)); + } + + $this->courses->save($course); + + return $course; + } + + /** پکیجی که این خدمت را پوشش نمی‌دهد، به این دوره وصل نمی‌شود. */ + private function assertPackageCovers(PatientPackage $package, CourseProtocol $protocol): void + { + $serviceId = (int) $protocol->getServiceItem()->getId(); + + if (!in_array($serviceId, $package->getPackage()->serviceIds(), true)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'پکیج انتخاب‌شده این خدمت را پوشش نمی‌دهد', + 422, + 'patient_package_uuid', + ); + } + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 3d7271d5..d3531e15 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -121,6 +121,9 @@ final class GlobalTables // سرویس‌های یک پکیج جزئی از تعریف همان پکیج‌اند، نه دادهٔ مستقل. \App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class, + // پارامترهای هر جلسه جزئی از تعریف همان پروتکل‌اند. + \App\Course\Entity\CourseProtocolStep::class => \App\Course\Entity\CourseProtocol::class, + \App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class, \App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class, diff --git a/tests/Course/CourseDocsCaptureTest.php b/tests/Course/CourseDocsCaptureTest.php new file mode 100644 index 00000000..c6159ca9 --- /dev/null +++ b/tests/Course/CourseDocsCaptureTest.php @@ -0,0 +1,34 @@ +createUser(['ROLE_USER','ROLE_CLINIC']); + $clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush(); + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); + $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); + $pu = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId()); + $this->em->persist($patient); $this->em->flush(); + $item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(30); $item->setPriceRials(5000000); + $this->em->persist($item); $this->em->flush(); + $d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); }; + $p = $this->authJson('POST','/api/v1/course-protocols',$user,[ + 'service_uuid'=>$item->getUuid(),'session_count'=>8,'min_days'=>21,'ideal_days'=>28,'max_days'=>45, + 'steps'=>[['session_number'=>1,'params'=>['energy'=>12]],['session_number'=>2,'params'=>['energy'=>14]]], + ]); + $d('PROTOCOL_CREATE', $p); + $c = $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]); + $d('COURSE_CREATE', $c); + $d('COURSE_SHOW', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}",$user)); + $d('NEXT_SLOT', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",$user)); + $d('PATIENT_COURSES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/courses",$user)); + $d('DUPLICATE', $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']])); + $d('ABANDON', $this->authJson('POST',"/api/v1/treatment-course/{$c['data']['uuid']}/abandon",$user,['reason'=>'انصراف بیمار'])); + self::assertTrue(true); + } +} diff --git a/tests/Course/TreatmentCourseTest.php b/tests/Course/TreatmentCourseTest.php new file mode 100644 index 00000000..b5ada23b --- /dev/null +++ b/tests/Course/TreatmentCourseTest.php @@ -0,0 +1,453 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک دوره'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر دوره'); + $this->em->persist($doctor); + + $patientUser = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); + $this->em->persist($patient); + $this->em->flush(); + + return [$user, $section, $address, $doctor, $patient]; + } + + private function service(ServiceSection $section, string $name = 'لیزر فول‌بادی'): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes(30); + $item->setPriceRials(5_000_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param array $extra */ + private function protocol(User $user, ServiceItem $service, array $extra = []): array + { + $body = $this->authJson('POST', '/api/v1/course-protocols', $user, $extra + [ + 'service_uuid' => $service->getUuid(), + 'session_count' => 8, + 'min_days' => 21, + 'ideal_days' => 28, + 'max_days' => 45, + 'steps' => [ + ['session_number' => 1, 'params' => ['energy' => 12]], + ['session_number' => 2, 'params' => ['energy' => 14]], + ['session_number' => 3, 'params' => ['energy' => 16]], + ['session_number' => 4, 'params' => ['energy' => 18]], + ], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']; + } + + private function startCourse(User $user, PatientRecord $patient, string $protocolUuid): array + { + $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ + 'patient_uuid' => $patient->getUuid(), + 'protocol_uuid' => $protocolUuid, + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']; + } + + private function courseEntity(string $uuid): TreatmentCourse + { + return static::getContainer()->get(TreatmentCourseRepository::class)->findByUuid($uuid); + } + + private function linker(): CourseSessionLinker + { + return static::getContainer()->get(CourseSessionLinker::class); + } + + // ── پروتکل ────────────────────────────────────────────────────────────── + + public function testProtocolKeepsItsStepsAndSpacing(): void + { + [$user, $section] = $this->clinicWithPatient(); + $protocol = $this->protocol($user, $this->service($section)); + + self::assertSame(8, $protocol['session_count']); + self::assertSame([21, 28, 45], [$protocol['min_days'], $protocol['ideal_days'], $protocol['max_days']]); + self::assertSame([1, 2, 3, 4], array_column($protocol['steps'], 'session_number')); + self::assertSame(16, $protocol['steps'][2]['params']['energy']); + } + + /** ترتیب فاصله‌ها معنا دارد؛ حداکثر کوچک‌تر از حداقل یعنی پروتکل غیرقابل اجرا. */ + public function testSpacingMustBeOrdered(): void + { + [$user, $section] = $this->clinicWithPatient(); + + $this->authJson('POST', '/api/v1/course-protocols', $user, [ + 'service_uuid' => $this->service($section)->getUuid(), + 'session_count' => 6, + 'min_days' => 30, + 'ideal_days' => 20, + 'max_days' => 45, + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** دورهٔ یک‌جلسه‌ای همان نوبت تکی است. */ + public function testASingleSessionCourseIsRejected(): void + { + [$user, $section] = $this->clinicWithPatient(); + + $this->authJson('POST', '/api/v1/course-protocols', $user, [ + 'service_uuid' => $this->service($section)->getUuid(), + 'session_count' => 1, + 'min_days' => 7, + 'ideal_days' => 7, + 'max_days' => 14, + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testOneProtocolPerService(): void + { + [$user, $section] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->protocol($user, $service); + + $this->authJson('POST', '/api/v1/course-protocols', $user, [ + 'service_uuid' => $service->getUuid(), + 'session_count' => 4, + 'min_days' => 7, + 'ideal_days' => 14, + 'max_days' => 21, + ]); + + self::assertSame(422, $this->responseCode()); + } + + // ── شروع دوره ─────────────────────────────────────────────────────────── + + public function testStartingACourseCreatesEverySessionWithItsParams(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $protocol = $this->protocol($user, $this->service($section)); + + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + self::assertCount(8, $course['sessions']); + self::assertSame(array_fill(0, 8, 'planned'), array_column($course['sessions'], 'status')); + self::assertSame(12, $course['sessions'][0]['params']['energy']); + self::assertSame(18, $course['sessions'][3]['params']['energy']); + + // جلسات ۵ تا ۸ پارامتری در پروتکل ندارند — آرایهٔ خالی، نه خطا. + self::assertSame([], (array) $course['sessions'][7]['params']); + + self::assertSame( + ['completed' => 0, 'booked' => 0, 'planned' => 8, 'skipped' => 0, 'total' => 8], + array_intersect_key($course['progress'], array_flip(['completed', 'booked', 'planned', 'skipped', 'total'])), + ); + self::assertSame(1, $course['progress']['next_session_number']); + } + + /** ⭐ تغییر پروتکل نباید دورهٔ در جریان را عوض کند. */ + public function testChangingTheProtocolLeavesRunningCoursesAlone(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $protocol = $this->protocol($user, $this->service($section)); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $this->authJson('PATCH', "/api/v1/course-protocol/{$protocol['uuid']}", $user, [ + 'session_count' => 12, + 'min_days' => 30, + 'ideal_days' => 40, + 'max_days' => 60, + ]); + self::assertSame(200, $this->responseCode()); + + $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; + + self::assertSame(8, $after['session_count']); + self::assertSame([21, 28, 45], [$after['min_days'], $after['ideal_days'], $after['max_days']]); + self::assertCount(8, $after['sessions']); + } + + public function testASecondActiveCourseForTheSameServiceIsRejected(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $protocol = $this->protocol($user, $this->service($section)); + + $first = $this->startCourse($user, $patient, $protocol['uuid']); + + $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ + 'patient_uuid' => $patient->getUuid(), + 'protocol_uuid' => $protocol['uuid'], + ]); + + self::assertSame(422, $this->responseCode()); + // پیام باید شناسهٔ دورهٔ موجود را بدهد تا اپراتور بتواند برود سراغش. + self::assertStringContainsString($first['uuid'], $body['errors'][0]['message']); + } + + /** رهاکردن دوره جا را برای دورهٔ تازه باز می‌کند. */ + public function testAbandoningACourseFreesTheSlotForANewOne(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $protocol = $this->protocol($user, $this->service($section)); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, ['reason' => '']); + self::assertSame(422, $this->responseCode(), 'رهاکردن بدون دلیل نباید پذیرفته شود'); + + $abandoned = $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, [ + 'reason' => 'انصراف بیمار', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame('abandoned', $abandoned['data']['status']); + + $this->startCourse($user, $patient, $protocol['uuid']); + } + + // ── پیشرفت و لنگر متحرک ───────────────────────────────────────────────── + + public function testProgressCountsCompletedSessionsAndPointsAtTheNextOne(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + $protocol = $this->protocol($user, $service); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3); + + $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; + + self::assertSame(3, $after['progress']['completed']); + self::assertSame(8, $after['progress']['total']); + self::assertSame(4, $after['progress']['next_session_number']); + self::assertSame(18, $after['progress']['next_params']['energy']); + } + + /** ⭐ فاصله از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. */ + public function testTheSuggestionAnchorsOnTheLastCompletedSession(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + $protocol = $this->protocol($user, $service); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $completedAt = time() - 10 * 86400; + $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3, $completedAt); + + $body = $this->authJson( + 'GET', + "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", + $user, + ); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $data = $body['data']; + + self::assertSame(4, $data['session_number']); + self::assertSame(18, $data['params']['energy']); + self::assertSame($completedAt + 21 * 86400, $data['range']['min']); + self::assertSame($completedAt + 45 * 86400, $data['range']['max']); + self::assertSame($completedAt + 28 * 86400, $data['ideal_at']); + self::assertNull($data['warning']); + } + + public function testPassingTheMaximumGapProducesAWarning(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + $protocol = $this->protocol($user, $service); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1, time() - 50 * 86400); + + $data = $this->authJson( + 'GET', + "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", + $user, + )['data']; + + self::assertNotNull($data['warning']); + self::assertStringContainsString('45', $data['warning'], 'پیام باید حداکثر فاصلهٔ همان دوره را بگوید'); + } + + // ── لغو یک جلسهٔ وسط دوره ─────────────────────────────────────────────── + + /** ⭐ لغو یک جلسه فقط همان جلسه را برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده. */ + public function testCancellingOneSessionOnlyResetsThatSession(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + $protocol = $this->protocol($user, $service); + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $entity = $this->courseEntity($course['uuid']); + $sessions = $entity->getSessions()->toArray(); + usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); + $this->linker()->link($sessions[0], $appointment); + + $second = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); + $this->linker()->link($this->reloadSession($sessions[1]->getUuid()), $second); + + $before = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; + self::assertSame(['booked', 'booked'], array_slice(array_column($before['sessions'], 'status'), 0, 2)); + + self::assertTrue($this->linker()->unlink($this->reloadAppointment($appointment->getUuid()))); + + $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; + + self::assertSame('planned', $after['sessions'][0]['status']); + self::assertSame('booked', $after['sessions'][1]['status'], 'بقیهٔ جلسات نباید دست بخورند'); + } + + public function testTheCourseCompletesOnlyWhenEverySessionIsDone(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + // پروتکل کوتاه تا کل دوره در تست تمام شود. + $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ + 'service_uuid' => $service->getUuid(), + 'session_count' => 2, + 'min_days' => 7, + 'ideal_days' => 14, + 'max_days' => 21, + ])['data']; + + $course = $this->startCourse($user, $patient, $protocol['uuid']); + + $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); + self::assertSame('active', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); + + $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); + self::assertSame('completed', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); + } + + // ── جداسازی محیط ──────────────────────────────────────────────────────── + + public function testAnotherClinicCannotSeeTheCourse(): void + { + [$owner, $section, , , $patient] = $this->clinicWithPatient(); + [$other] = $this->clinicWithPatient(); + + $protocol = $this->protocol($owner, $this->service($section)); + $course = $this->startCourse($owner, $patient, $protocol['uuid']); + + $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $other); + self::assertSame(404, $this->responseCode()); + } + + // ── کمکی ──────────────────────────────────────────────────────────────── + + private function reloadSession(string $uuid): CourseSession + { + return static::getContainer()->get(CourseSessionRepository::class)->findByUuid($uuid); + } + + private function reloadAppointment(string $uuid): \App\Appointment\Entity\Appointment + { + return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) + ->getRepository(\App\Appointment\Entity\Appointment::class) + ->findOneBy(['uuid' => $uuid]); + } + + private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): \App\Appointment\Entity\Appointment + { + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + + $start = time() + 86400 + (++$this->slotCursor) * 3600; + + $appointment = new \App\Appointment\Entity\Appointment( + $em->getRepository(Doctor::class)->find($doctor->getId()), + $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), + $start, + $start + 1800, + ); + $appointment->assignTenantPair('clinic', $clinicId); + $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); + $appointment->setPatientName('بیمار دوره'); + + $em->persist($appointment); + $em->flush(); + + return $appointment; + } + + /** n جلسهٔ بعدی را رزرو و انجام‌شده می‌کند. */ + private function completeSessions( + string $courseUuid, + Doctor $doctor, + PatientRecord $patient, + ServiceItem $service, + int $clinicId, + int $count, + ?int $completedAt = null, + ): void { + for ($i = 0; $i < $count; $i++) { + $course = $this->courseEntity($courseUuid); + $sessions = $course->plannedSessions(); + + usort($sessions, static fn (CourseSession $a, CourseSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + $appointment = $this->appointment($doctor, $patient, $service, $clinicId); + + $this->linker()->link($this->reloadSession($sessions[0]->getUuid()), $appointment); + $this->linker()->complete($this->reloadAppointment($appointment->getUuid()), $completedAt); + } + } +}