feat(course): treatment courses with protocol-driven session planning
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
|
||||
<Route path="packages" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PackagesPage /></RoleRoute>} />
|
||||
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
|
||||
<Route path="course-protocols" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CourseProtocolsPage /></RoleRoute>} />
|
||||
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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<ApiResponse<CourseProtocol[]>>('/api/v1/course-protocols'),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<ApiResponse<CourseProtocol>>('/api/v1/course-protocols', body),
|
||||
onSuccess: () => {
|
||||
toast.success('پروتکل ساخته شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.patch<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('پروتکل بهروزرسانی شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'بهروزرسانی پروتکل ناموفق بود'),
|
||||
});
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<CourseProtocol>>(`/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<ApiResponse<TreatmentCourse[]>>(`/api/v1/patient/${patientUuid}/courses`),
|
||||
enabled: !!patientUuid,
|
||||
});
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) =>
|
||||
api.post<ApiResponse<TreatmentCourse>>('/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<ApiResponse<TreatmentCourse>>(`/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<ApiResponse<TreatmentCourse>>(`/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<ApiResponse<{ booked: number; remaining: number; message: string | null }>>(
|
||||
`/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<ApiResponse<NextSlotSuggestion>>(
|
||||
`/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`,
|
||||
),
|
||||
enabled: !!courseUuid && !!branchUuid,
|
||||
});
|
||||
|
||||
return { suggestion: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
@@ -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<Draft | null>(null);
|
||||
|
||||
const { data: servicesData } = useQuery({
|
||||
queryKey: ['service-items-for-courses'],
|
||||
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/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<CourseProtocol>[] = [
|
||||
{
|
||||
key: 'service_name',
|
||||
header: 'سرویس',
|
||||
render: (p) => <span style={{ fontWeight: 600 }}>{p.service_name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'session_count',
|
||||
header: 'تعداد جلسه',
|
||||
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'spacing',
|
||||
header: 'فاصله (روز)',
|
||||
render: (p) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
||||
حداقل {p.min_days} · ایدهآل {p.ideal_days} · حداکثر {p.max_days}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'steps',
|
||||
header: 'پارامتر جلسات',
|
||||
render: (p) => (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
header: 'وضعیت',
|
||||
render: (p) => <ActiveBadge active={p.active} />,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="پروتکل دوره"
|
||||
description="دورهٔ چندجلسهای هر خدمت: تعداد جلسه و فاصلهٔ مجاز بین جلسات."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canManage ? (
|
||||
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY, steps: [] })}>
|
||||
<PlusIcon style={{ width: 15 }} /> پروتکل تازه
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در پروتکلها..."
|
||||
emptyMessage="هنوز پروتکلی تعریف نشده است"
|
||||
actions={(p) =>
|
||||
canManage ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
uuid: p.uuid,
|
||||
service_uuid: p.service_uuid,
|
||||
session_count: p.session_count,
|
||||
min_days: p.min_days,
|
||||
ideal_days: p.ideal_days,
|
||||
max_days: p.max_days,
|
||||
prefer_same_resource: p.prefer_same_resource,
|
||||
steps: p.steps.map((s) => ({
|
||||
session_number: s.session_number,
|
||||
energy: String(s.params.energy ?? ''),
|
||||
})),
|
||||
})
|
||||
}
|
||||
>
|
||||
ویرایش
|
||||
</button>
|
||||
{p.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={deactivate.isPending}
|
||||
onClick={() => deactivate.mutate(p.uuid)}
|
||||
>
|
||||
غیرفعال
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.uuid ? 'ویرایش پروتکل' : 'پروتکل تازه'}
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={
|
||||
!draft ||
|
||||
(!draft.uuid && draft.service_uuid === '') ||
|
||||
draft.session_count < 2 ||
|
||||
orderInvalid ||
|
||||
create.isPending ||
|
||||
update.isPending
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{!draft.uuid && (
|
||||
<div className="field">
|
||||
<label>سرویس</label>
|
||||
<SearchableSelect
|
||||
value={draft.service_uuid}
|
||||
onChange={(v) => setDraft({ ...draft, service_uuid: String(v ?? '') })}
|
||||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
placeholder="انتخاب سرویس"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>هر سرویس یک پروتکل دارد.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field" style={{ maxWidth: 200 }}>
|
||||
<label htmlFor="cp-sessions">تعداد جلسه</label>
|
||||
<input
|
||||
id="cp-sessions"
|
||||
className="input"
|
||||
type="number"
|
||||
min={2}
|
||||
value={draft.session_count}
|
||||
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
|
||||
/>
|
||||
{draft.session_count < 2 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
دورهٔ کمتر از دو جلسه همان نوبت تکی است.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{([
|
||||
['min_days', 'حداقل (روز)'],
|
||||
['ideal_days', 'ایدهآل (روز)'],
|
||||
['max_days', 'حداکثر (روز)'],
|
||||
] as const).map(([key, label]) => (
|
||||
<div className="field" key={key} style={{ maxWidth: 150 }}>
|
||||
<label htmlFor={`cp-${key}`}>{label}</label>
|
||||
<input
|
||||
id={`cp-${key}`}
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft[key]}
|
||||
onChange={(e) => setDraft({ ...draft, [key]: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{orderInvalid && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
ترتیب باید حداقل ≤ ایدهآل ≤ حداکثر باشد.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.prefer_same_resource}
|
||||
onChange={(e) => setDraft({ ...draft, prefer_same_resource: e.target.checked })}
|
||||
/>
|
||||
تا حد امکان همان منبع جلسهٔ قبل
|
||||
</label>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>پارامتر جلسات (اختیاری)</h3>
|
||||
|
||||
{draft.steps.map((step, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 110 }}
|
||||
type="number"
|
||||
min={1}
|
||||
max={draft.session_count}
|
||||
value={step.session_number}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
steps: draft.steps.map((s, i) =>
|
||||
i === index ? { ...s, session_number: Number(e.target.value) } : s,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 140 }}
|
||||
type="number"
|
||||
placeholder="سطح انرژی"
|
||||
value={step.energy}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, i) => i !== index) })}
|
||||
aria-label="حذف پارامتر"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...draft,
|
||||
steps: [...draft.steps, { session_number: draft.steps.length + 1, energy: '' }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن پارامتر جلسه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => <TabServices color={c} /> },
|
||||
@@ -51,6 +53,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
|
||||
{ key: 'payments', label: 'پرداختها', icon: (c) => <TabCard color={c} /> },
|
||||
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
|
||||
{ key: 'packages', label: 'پکیجها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
|
||||
{ key: 'courses', label: 'دورههای درمان', icon: (c) => <ArrowPathRoundedSquareIcon style={{ width: 18, color: c }} /> },
|
||||
{ key: 'notes', label: 'یادداشتها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
|
||||
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
|
||||
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
|
||||
@@ -264,6 +267,8 @@ export default function PatientDetailPage() {
|
||||
<WalletTab uuid={uuid!} />
|
||||
) : tab === 'packages' ? (
|
||||
<PackagesTab uuid={uuid!} />
|
||||
) : tab === 'courses' ? (
|
||||
<CoursesTab uuid={uuid!} />
|
||||
) : tab === 'callcenter' ? (
|
||||
<CallCenterTab uuid={uuid!} />
|
||||
) : tab === 'attach' ? (
|
||||
@@ -1108,3 +1113,77 @@ function PackagesTab({ uuid }: { uuid: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* دورههای درمان بیمار.
|
||||
*
|
||||
* پیشرفت از سرور میآید («۳ از ۸»)؛ فرانت نمیشمارد، چون وضعیت جلسات آنجاست.
|
||||
*/
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||||
<label>شروع دورهٔ تازه</label>
|
||||
<SearchableSelect
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(String(v ?? ''))}
|
||||
options={active.map((p) => ({
|
||||
value: p.uuid,
|
||||
label: `${p.service_name} — ${p.session_count} جلسه`,
|
||||
}))}
|
||||
placeholder="انتخاب پروتکل"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={selected === '' || start.isPending}
|
||||
onClick={async () => {
|
||||
await start.mutateAsync({ protocol_uuid: selected });
|
||||
setSelected('');
|
||||
}}
|
||||
>
|
||||
شروع دوره
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||||
) : courses.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
این بیمار دورهٔ درمانی ندارد
|
||||
</div>
|
||||
) : (
|
||||
courses.map((c) => (
|
||||
<div key={c.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontWeight: 600 }}>{c.service_name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>شروع {formatDate(c.started_at)}</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 14 }}>
|
||||
جلسهٔ <strong>{c.progress.completed}</strong> از {c.progress.total}
|
||||
</span>
|
||||
|
||||
{c.status === 'active' ? (
|
||||
<span className="badge green"><span className="bdot" />در جریان</span>
|
||||
) : (
|
||||
<span className="badge"><span className="bdot" />{c.status === 'completed' ? 'تمامشده' : 'رهاشده'}</span>
|
||||
)}
|
||||
|
||||
<Link className="btn secondary sm" style={{ marginRight: 'auto' }} to={`/admin/treatment-course/${c.uuid}`}>
|
||||
جزئیات دوره
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<CourseSessionRow['status'], { label: string; className: string }> = {
|
||||
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<CourseSessionRow>[] = [
|
||||
{
|
||||
key: 'session_number',
|
||||
header: 'جلسه',
|
||||
render: (s) => <span style={{ fontWeight: 600 }}>{s.session_number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (s) => (
|
||||
<span className={SESSION_STATUS[s.status].className}>
|
||||
<span className="bdot" />
|
||||
{SESSION_STATUS[s.status].label}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slot_start',
|
||||
header: 'تاریخ نوبت',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 13 }}>{s.slot_start === null ? '—' : formatDate(s.slot_start)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'params',
|
||||
header: 'پارامتر',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||
{Object.entries(s.params).length === 0
|
||||
? '—'
|
||||
: Object.entries(s.params)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('، ')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed_at',
|
||||
header: 'انجامشده در',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{s.completed_at === null ? '—' : formatDate(s.completed_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={course ? `دورهٔ ${course.service_name}` : 'دورهٔ درمان'}
|
||||
description="پیشرفت دوره، جلسات و پیشنهاد تاریخ جلسهٔ بعدی."
|
||||
backTo="/admin/patients"
|
||||
/>
|
||||
|
||||
{course && (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 15 }}>
|
||||
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
||||
رزروشده: {course.progress.booked} · باقیمانده: {course.progress.planned}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
فاصله: حداقل {course.min_days} · ایدهآل {course.ideal_days} · حداکثر {course.max_days} روز
|
||||
</span>
|
||||
{course.status !== 'active' && (
|
||||
<span className="badge red">
|
||||
<span className="bdot" />
|
||||
{course.status === 'completed' ? 'تمامشده' : 'رهاشده'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{course.abandon_reason && (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>دلیل رهاکردن: {course.abandon_reason}</span>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 220, margin: 0 }}>
|
||||
<label>شعبه برای پیشنهاد وقت</label>
|
||||
<SearchableSelect
|
||||
value={branchUuid}
|
||||
onChange={(v) => setBranchUuid(String(v ?? ''))}
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
placeholder="انتخاب شعبه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canManage && course.status === 'active' && (
|
||||
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
|
||||
رهاکردن دوره
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{suggestion && suggestion.session_number !== null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
|
||||
<span>
|
||||
جلسهٔ بعدی: <strong>{suggestion.session_number}</strong>
|
||||
{suggestion.ideal_at !== undefined && ` · تاریخ ایدهآل ${formatDate(suggestion.ideal_at)}`}
|
||||
</span>
|
||||
{suggestion.range && (
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
|
||||
بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)}
|
||||
</span>
|
||||
)}
|
||||
{suggestion.suggested_slots.length > 0 && (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
نزدیکترین وقتها:{' '}
|
||||
{suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')}
|
||||
</span>
|
||||
)}
|
||||
{suggestion.warning && (
|
||||
<span style={{ color: 'var(--warning)' }}>{suggestion.warning}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={course?.sessions ?? []}
|
||||
loading={loading}
|
||||
emptyMessage="این دوره جلسهای ندارد"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={abandoning}
|
||||
title="رهاکردن دوره"
|
||||
message="جلسات باقیمانده برنامهریزیشده میمانند و دوره از فهرست فعال خارج میشود."
|
||||
confirmLabel="رهاکن"
|
||||
danger
|
||||
loading={abandon.isPending}
|
||||
onCancel={() => setAbandoning(false)}
|
||||
onConfirm={async () => {
|
||||
await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره');
|
||||
setAbandoning(false);
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="abandon-reason">دلیل</label>
|
||||
<input
|
||||
id="abandon-reason"
|
||||
className="input"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="مثلاً: انصراف بیمار"
|
||||
/>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1251,3 +1251,75 @@ export interface CreditLedger {
|
||||
package: PatientPackage;
|
||||
rows: CreditLedgerRow[];
|
||||
}
|
||||
|
||||
// ── دورهٔ درمان (تسک ۱۲) ──────────────────────────────────────────────────────
|
||||
|
||||
export interface CourseProtocolStep {
|
||||
session_number: number;
|
||||
params: Record<string, string | number | boolean>;
|
||||
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<string, string | number | boolean>;
|
||||
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<string, string | number | boolean>;
|
||||
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<string, string | number | boolean>;
|
||||
ideal_at?: number;
|
||||
range?: { min: number; max: number };
|
||||
suggested_slots: { start: number; end: number }[];
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 .` | ✅ | دو کامیت جدا |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260731074710 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260731074758 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseProtocolStep;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class CourseProtocolController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/course-protocols', name: 'course_protocol_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseBooker;
|
||||
use App\Course\Service\CourseProgressCalculator;
|
||||
use App\Course\Service\CourseScheduler;
|
||||
use App\Course\Service\CourseStarter;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCourseController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly CourseStarter $starter,
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly CourseBooker $booker,
|
||||
private readonly CourseProgressCalculator $progress,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/treatment-course', name: 'treatment_course_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['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<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* پروتکل دوره — «لیزر فولبادی: ۸ جلسه، ۲۱/۲۸/۴۵ روز».
|
||||
*
|
||||
* سه فاصله سه معنای متفاوت دارند: `min` زودترین زمانی که از نظر درمانی مجاز است،
|
||||
* `ideal` بهترین، و `max` جایی که دیرتر از آن اثر دوره افت میکند. برنامهریز به
|
||||
* **نزدیکترین به ایدهآل** میرسد، نه اولین وقت خالی.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseProtocolRepository::class)]
|
||||
#[ORM\Table(name: 'course_protocols')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_protocol_service', columns: ['service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_protocols_tenant')]
|
||||
class CourseProtocol
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\Column(name: 'prefer_same_resource', type: 'boolean', options: ['default' => true])]
|
||||
private bool $preferSameResource = true;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
/** @var Collection<int, CourseProtocolStep> */
|
||||
#[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<int, CourseProtocolStep> */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* پارامترهای یک جلسه از پروتکل — «جلسهٔ ۳: انرژی ۱۶».
|
||||
*
|
||||
* `params` عمداً آزاد است چون هر تخصص پارامتر خودش را دارد (انرژی، دوز، ضخامت)، ولی
|
||||
* فقط اسکالر و **هیچ منطقی به مقدارش وابسته نیست**: فقط کپی و نمایش میشود. لحظهای
|
||||
* که کدی روی `params['energy']` شرط بگذارد، این آزادی به بدهی تبدیل میشود.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'course_protocol_steps')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_step', columns: ['protocol_id', 'session_number'])]
|
||||
class CourseProtocolStep
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class, inversedBy: 'steps')]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar> */
|
||||
#[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<string, mixed> $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<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'session_number' => $this->sessionNumber,
|
||||
'params' => (object) ($this->params ?? []),
|
||||
'override_duration_minutes' => $this->overrideDurationMinutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک جلسه از دوره — برنامهریزیشده، رزروشده، انجامشده یا ردشده.
|
||||
*
|
||||
* `params` از پروتکل **کپی** میشود: بیمار جلسهٔ سوم را با انرژی ۱۶ انجام داده، و اگر
|
||||
* پروتکل فردا عوض شود، پروندهٔ او نباید بگوید ۱۸ بوده.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseSessionRepository::class)]
|
||||
#[ORM\Table(name: 'course_sessions')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_course_session', columns: ['course_id', 'session_number'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_session_appointment', columns: ['appointment_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_sessions_tenant')]
|
||||
#[ORM\Index(columns: ['course_id', 'session_number'], name: 'idx_sessions_course')]
|
||||
class CourseSession
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
public const STATUS_BOOKED = 'booked';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TreatmentCourse::class, inversedBy: 'sessions')]
|
||||
#[ORM\JoinColumn(name: 'course_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TreatmentCourse $course;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar>|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<string, mixed> $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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان یک بیمار.
|
||||
*
|
||||
* چهار عدد پروتکل **کپی** میشوند نه ارجاع: تغییر پروتکل فردا نباید دورهٔ در جریان را
|
||||
* عوض کند — همان تصمیمی که در `appointment_segments` و `patient_packages` گرفته شد.
|
||||
*
|
||||
* یکتایی «یک دورهٔ فعال per (بیمار، سرویس)» با `activeCourseKey` گرفته میشود، همان
|
||||
* الگوی `Appointment::activeSlotKey`: MariaDB کلید یکتای جزئی ندارد، ولی کلیدی که در
|
||||
* حالتهای غیرفعال `null` میشود همان کار را میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TreatmentCourseRepository::class)]
|
||||
#[ORM\Table(name: 'treatment_courses')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status', 'started_at'], name: 'idx_courses_tenant')]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'status'], name: 'idx_courses_patient')]
|
||||
class TreatmentCourse
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private PatientRecord $patientRecord;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class)]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientPackage::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_package_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?PatientPackage $patientPackage = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
|
||||
#[ORM\JoinColumn(name: 'preferred_resource_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicResource $preferredResource = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 12, options: ['default' => 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<int, CourseSession> */
|
||||
#[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<int, CourseSession> */
|
||||
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<CourseSession> جلساتی که هنوز رزرو نشدهاند، به ترتیب شماره */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseProtocol> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseSession> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<TreatmentCourse> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Booking\Service\BookingService;
|
||||
use App\Appointment\Booking\Service\HoldService;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رزرو یکجای جلسات باقیماندهٔ دوره.
|
||||
*
|
||||
* سه قاعده که ترتیبشان مهم است:
|
||||
*
|
||||
* ۱. **همه یا هیچ** — کل حلقه در یک تراکنش. رزرو نیمهکاره بدترین حالت است: بیمار فکر
|
||||
* میکند دورهاش رزرو شده و نصفش نیست.
|
||||
* ۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله میگیرد، نه از شروع دوره.
|
||||
* ۳. **سقف افق جستجو** — جلساتی که بیرون بازهٔ مجاز میافتند `planned` میمانند و
|
||||
* پیام روشن برمیگردد؛ خطا نیستند.
|
||||
*/
|
||||
final class CourseBooker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly HoldService $holds,
|
||||
private readonly BookingService $booking,
|
||||
private readonly CourseSessionLinker $linker,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{booked: int, remaining: int, message: string|null}
|
||||
*/
|
||||
public function bookAll(TreatmentCourse $course, DoctorAddress $address, Doctor $doctor, User $operator, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
|
||||
return $this->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<string, list<ClinicResource>> */
|
||||
private function assignmentOf(AvailableSlot $slot): array
|
||||
{
|
||||
return $slot->assignment->byRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
|
||||
/**
|
||||
* پیشرفت دوره — «جلسهٔ ۳ از ۸».
|
||||
*
|
||||
* شمارش از خودِ جلسات میآید نه از یک شمارنده؛ همان دلیل دفتر اعتبار تسک ۱۱: عددی که
|
||||
* جدا از داده نگه داشته شود، بالاخره با آن اختلاف پیدا میکند.
|
||||
*/
|
||||
final class CourseProgressCalculator
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\Service\AvailabilityEngine;
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
|
||||
/**
|
||||
* برنامهریزی جلسات دوره: پیشنهاد جلسهٔ بعدی و رزرو یکجا.
|
||||
*
|
||||
* ## لنگر متحرک
|
||||
*
|
||||
* فاصله همیشه از **جلسهٔ قبلی** حساب میشود، نه از شروع دوره. اگر جلسهٔ ۲ سه روز دیرتر
|
||||
* افتاد، جلسهٔ ۳ هم جابهجا میشود — وگرنه تأخیر یک جلسه، فاصلهٔ بقیه را خراب میکند.
|
||||
*
|
||||
* ## نزدیکترین به ایدهآل، نه اولین آزاد
|
||||
*
|
||||
* ۲۸ روز ایدهآل است؛ روز ۲۱ (حداقلِ مجاز) از نظر درمانی بدتر از روز ۲۷ است. پس بین
|
||||
* وقتهای موجود، آن که فاصلهاش تا ایدهآل کمتر است برنده میشود.
|
||||
*/
|
||||
final class CourseScheduler
|
||||
{
|
||||
/** سقف جستجوی تسک ۰۶ — جلسات بیرون این بازه `planned` میمانند. */
|
||||
public const SEARCH_HORIZON_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly AvailabilityEngine $availability,
|
||||
private readonly PolicyResolver $policies,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* فاصلهٔ مؤثر: سختگیرانهترین بین پروتکل دوره و قانون `spacing` تسک ۰۹.
|
||||
*
|
||||
* قانون کلینیک نباید با پروتکل بجنگد؛ هر کدام سختگیرتر بود همان اجرا میشود.
|
||||
*/
|
||||
public function effectiveMinDays(TreatmentCourse $course, ?int $at = null): int
|
||||
{
|
||||
$service = $course->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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها جایی که پیوند «نوبت ↔ جلسهٔ دوره» نوشته میشود.
|
||||
*
|
||||
* پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`)
|
||||
* تا لیست نوبتهای پنل بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را
|
||||
* پیدا کند. دو ستون یعنی دو فرصت برای واگرایی، پس **فقط این کلاس** مینویسدشان.
|
||||
*/
|
||||
final class CourseSessionLinker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseSessionRepository $sessions,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function link(CourseSession $session, Appointment $appointment): void
|
||||
{
|
||||
$session->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* شروع دوره از روی پروتکل.
|
||||
*
|
||||
* همهٔ جلسات **همین لحظه** ساخته میشوند (با وضعیت `planned`) نه هنگام رزرو: بیمار باید
|
||||
* از روز اول ببیند «۸ جلسه» یعنی چه، و پارامتر هر جلسه هم همان لحظه از پروتکل کپی
|
||||
* میشود تا تغییر بعدی پروتکل پروندهٔ او را عوض نکند.
|
||||
*/
|
||||
final class CourseStarter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
) {}
|
||||
|
||||
public function start(
|
||||
PatientRecord $patient,
|
||||
CourseProtocol $protocol,
|
||||
?PatientPackage $package = null,
|
||||
): TreatmentCourse {
|
||||
if (!$protocol->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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace App\Tests\Course;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class CourseDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('COURSE_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: COURSE_DOCS=1'); }
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Course;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseSessionLinker;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان — تسک ۱۲.
|
||||
*
|
||||
* «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی میشناخت. تستها روی سه
|
||||
* چیز تمرکز دارند: snapshot پروتکل، لنگر متحرک فاصلهها، و اینکه لغو یک جلسه بقیهٔ
|
||||
* دوره را خراب نکند.
|
||||
*/
|
||||
class TreatmentCourseTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user