Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote. - Eliminated unused domain event publishing for policies and waitlist in Schedule. - Cleaned up BookingEngineSeeder by removing package and policy related logic. - Updated SeedScenariosCommand to reflect removal of policies from output. - Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration. - Removed domain event assertions from tests related to resource blocking.
This commit is contained in:
@@ -77,17 +77,6 @@ import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import ResourceBookingPage from './pages/ResourceBookingPage';
|
||||
import PriceListsPage from './pages/PriceListsPage';
|
||||
import ResourceUtilizationPage from './pages/ResourceUtilizationPage';
|
||||
import PlanAccuracyPage from './pages/PlanAccuracyPage';
|
||||
import CancellationPolicyPage from './pages/CancellationPolicyPage';
|
||||
import WaitlistPage from './pages/WaitlistPage';
|
||||
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';
|
||||
import PolicyFormPage from './pages/PolicyFormPage';
|
||||
import PolicySimulationPage from './pages/PolicySimulationPage';
|
||||
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
||||
import BranchRoomsPage from './pages/BranchRoomsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
@@ -300,17 +289,6 @@ export default function App() {
|
||||
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
|
||||
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
|
||||
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
|
||||
<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="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
|
||||
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
|
||||
<Route path="reports/resource-utilization" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceUtilizationPage /></RoleRoute>} />
|
||||
<Route path="reports/plan-accuracy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PlanAccuracyPage /></RoleRoute>} />
|
||||
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
|
||||
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import { useCancellationPreview, useCancelAppointment } from '../hooks/useCancellation';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import { useCancelAppointment } from '../hooks/useCancellation';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
appointmentUuid: string;
|
||||
/** چه کسی لغو میکند — جریمه برای لغو کلینیک همیشه صفر است. */
|
||||
/** چه کسی لغو میکند — وضعیت نهایی نوبت از همین میآید. */
|
||||
by?: 'user' | 'doctor';
|
||||
onClose: () => void;
|
||||
onCancelled?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* لغو نوبت با نمایش پیامد مالی **قبل از** تأیید.
|
||||
*
|
||||
* پیشنمایش از همان محاسبهای میآید که خودِ لغو انجام میدهد، پس عددی که اپراتور
|
||||
* میبیند همان است که کسر میشود. دکمهٔ لغوِ بیپیشنمایش یعنی اپراتور جریمهٔ بیمار را
|
||||
* بعد از وقوعش کشف میکند.
|
||||
*/
|
||||
/** لغو نوبت با تأیید و دلیل اختیاری. */
|
||||
export default function CancelAppointmentDialog({
|
||||
open,
|
||||
appointmentUuid,
|
||||
@@ -27,7 +20,6 @@ export default function CancelAppointmentDialog({
|
||||
onCancelled,
|
||||
}: Props) {
|
||||
const [reason, setReason] = useState('');
|
||||
const { preview, loading } = useCancellationPreview(open ? appointmentUuid : undefined, by);
|
||||
const cancel = useCancelAppointment();
|
||||
|
||||
const close = () => {
|
||||
@@ -56,54 +48,6 @@ export default function CancelAppointmentDialog({
|
||||
}
|
||||
onCancel={close}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.9,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>در حال محاسبهٔ پیامد لغو…</span>
|
||||
) : !preview ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>
|
||||
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را دستی بررسی کنید.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>جریمهٔ لغو</span>
|
||||
<strong style={{ color: preview.penalty_rials > 0 ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{preview.penalty_rials > 0 ? formatRial(preview.penalty_rials) : 'بدون جریمه'}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>مبلغ پرداختشده</span>
|
||||
<span>{formatRial(preview.paid_rials)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>بازگشت اعتبار پکیج</span>
|
||||
<span>{preview.credit_refundable ? 'بله' : 'خیر'}</span>
|
||||
</div>
|
||||
|
||||
{preview.within_free_window && (
|
||||
<div style={{ color: 'var(--success)' }}>در بازهٔ لغو رایگان است.</div>
|
||||
)}
|
||||
|
||||
{preview.notes.map((note, i) => (
|
||||
<div key={i} style={{ color: 'var(--text-3)', fontSize: 12 }}>
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="cp-label mb-2" htmlFor="cancel-reason">
|
||||
دلیل لغو (اختیاری)
|
||||
|
||||
@@ -32,14 +32,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: '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: 'price-lists', label: 'لیستهای قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'utilization', label: 'بهرهوری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'plan-accuracy', label: 'دقت برنامه', icon: ChartBarIcon, to: '/admin/reports/plan-accuracy', 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'] },
|
||||
|
||||
@@ -1,138 +1,27 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type { CancellationPolicy, CancellationPreview, CancellationResult, WaitlistEntry } from '../types';
|
||||
|
||||
/**
|
||||
* سیاست لغو و لیست انتظار.
|
||||
* لغو نوبت — فقط انتقال وضعیت.
|
||||
*
|
||||
* پیشنمایش لغو همیشه از سرور میآید — همان محاسبهای که خودِ لغو انجام میدهد، تا عددی
|
||||
* که کاربر میبیند با آنچه کسر میشود یکی باشد.
|
||||
* سیاست لغو، جریمه و لیست انتظار از محصول حذف شدهاند، پس پیشنمایش مالی هم وجود ندارد:
|
||||
* چیزی برای پیشنمایش نمانده. لغو همان `PATCH /api/v1/appointment/{uuid}/status` است که
|
||||
* تایملاین نوبت و دلیل را هم ثبت میکند.
|
||||
*/
|
||||
const POLICY_KEY = ['cancellation-policy'];
|
||||
const WAITLIST_KEY = ['waitlist'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
interface PolicyResponse {
|
||||
default: CancellationPolicy | null;
|
||||
overrides: CancellationPolicy[];
|
||||
}
|
||||
|
||||
export function useCancellationPolicy() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: POLICY_KEY,
|
||||
queryFn: () => api.get<ApiResponse<PolicyResponse>>('/api/v1/cancellation-policy'),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.put<ApiResponse<CancellationPolicy>>('/api/v1/cancellation-policy', body),
|
||||
onSuccess: () => {
|
||||
toast.success('سیاست لغو ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: POLICY_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ سیاست ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
policy: query.data?.data?.default ?? null,
|
||||
overrides: query.data?.data?.overrides ?? [],
|
||||
loading: query.isLoading,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCancellationPreview(appointmentUuid: string | undefined, by: 'user' | 'doctor') {
|
||||
const query = useQuery({
|
||||
queryKey: ['cancellation-preview', appointmentUuid, by],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<CancellationPreview>>(
|
||||
`/api/v1/appointment/${appointmentUuid}/cancellation-preview?by=${by}`,
|
||||
),
|
||||
enabled: !!appointmentUuid,
|
||||
});
|
||||
|
||||
return { preview: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useCancelAppointment() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ uuid, by, reason }: { uuid: string; by: 'user' | 'doctor'; reason?: string }) =>
|
||||
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, {
|
||||
by,
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/appointment/${uuid}/status`, {
|
||||
status: by === 'user' ? 'cancelled_by_user' : 'cancelled_by_doctor',
|
||||
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
const notified = res.data.waitlist_notified;
|
||||
toast.success(
|
||||
notified > 0 ? `نوبت لغو شد و ${notified} نفر از لیست انتظار خبر شدند` : 'نوبت لغو شد',
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'لغو نوبت ناموفق بود'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* درخواستهایی که با یک زمان مشخص میخوانند — ابزار لحظهٔ آزاد شدن ظرفیت.
|
||||
*
|
||||
* جدا از فهرست است چون سؤال متفاوتی میپرسد: نه «چه کسی منتظر است» بلکه «چه کسی
|
||||
* برای **این** وقت منتظر است».
|
||||
*/
|
||||
export function useWaitlistMatches(params: { serviceUuid: string; start: number; branchUuid?: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['waitlist-matches', params],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<WaitlistEntry[]>>(
|
||||
`/api/v1/waitlist/matches?service_uuid=${params.serviceUuid}&start=${params.start}` +
|
||||
(params.branchUuid ? `&branch_uuid=${params.branchUuid}` : ''),
|
||||
),
|
||||
enabled: !!params.serviceUuid && params.start > 0,
|
||||
});
|
||||
|
||||
return { matches: Array.isArray(query.data?.data) ? query.data.data : [], loading: query.isFetching };
|
||||
}
|
||||
|
||||
export function useWaitlist(status?: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = [...WAITLIST_KEY, status ?? ''];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<WaitlistEntry[]>>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/waitlist/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('از لیست انتظار حذف شد');
|
||||
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
||||
toast.success('نوبت لغو شد');
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
},
|
||||
onError: (e) => fail(e, 'حذف ناموفق بود'),
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'لغو نوبت ناموفق بود'),
|
||||
});
|
||||
|
||||
return { entries: query.data?.data ?? [], loading: query.isLoading, remove };
|
||||
}
|
||||
|
||||
/** خلاصهٔ عدم حضور یک بیمار — نشان است نه مانع؛ مسدودسازی کارِ قانون `eligibility` است. */
|
||||
export function usePatientNoShows(patientUuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['patient-no-shows', patientUuid],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<{ count: number; threshold: number; window_days: number; at_risk: boolean }>>(
|
||||
`/api/v1/patient/${patientUuid}/no-shows`,
|
||||
),
|
||||
enabled: !!patientUuid,
|
||||
});
|
||||
|
||||
return { noShows: query.data?.data ?? null };
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type {
|
||||
Policy,
|
||||
PolicyCategory,
|
||||
PolicySchema,
|
||||
PolicySimulationRun,
|
||||
PolicyTemplate,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* قوانین کلینیک.
|
||||
*
|
||||
* دو نکتهٔ قراردادی که فرم به آنها وابسته است:
|
||||
* ۱. schema و الگوها از سرور میآیند، نه از فهرستی در فرانت — یک حقیقت، نه دو تا.
|
||||
* ۲. فعالسازی بدون آزمایشِ همان نسخه ۴۲۲ میگیرد؛ UI باید کاربر را اول به آزمایش ببرد.
|
||||
*/
|
||||
const POLICIES_KEY = ['policies'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
/** schema تقریباً هرگز عوض نمیشود؛ نگهداشتن طولانیاش هزینهای ندارد. */
|
||||
export function usePolicySchema() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-schema'],
|
||||
queryFn: () => api.get<ApiResponse<PolicySchema>>('/api/v1/policy-schema'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { schema: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyTemplates() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-templates'],
|
||||
queryFn: () => api.get<ApiResponse<PolicyTemplate[]>>('/api/v1/policy-templates'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { templates: query.data?.data ?? [], loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicies(category?: PolicyCategory | '') {
|
||||
const qc = useQueryClient();
|
||||
const key = [...POLICIES_KEY, category ?? ''];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<Policy[]>>(
|
||||
`/api/v1/policies${category ? `?category=${category}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/activate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون فعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'فعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/deactivate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون غیرفعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'غیرفعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
policies: query.data?.data ?? [],
|
||||
loading: query.isLoading,
|
||||
activate,
|
||||
deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePolicy(uuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Policy>>(`/api/v1/policy/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
return { policy: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyMutations() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<ApiResponse<Policy>>('/api/v1/policy', body),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون ساخته شد — حالا آزمایشش کنید');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت قانون ناموفق بود'),
|
||||
});
|
||||
|
||||
const newVersion = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/version`, body),
|
||||
onSuccess: (_d, v) => {
|
||||
toast.success('نسخهٔ تازه ثبت شد');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
qc.invalidateQueries({ queryKey: ['policy', v.uuid] });
|
||||
},
|
||||
onError: (e) => fail(e, 'ثبت نسخه ناموفق بود'),
|
||||
});
|
||||
|
||||
return { create, newVersion };
|
||||
}
|
||||
|
||||
export function usePolicySimulation(uuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['policy-simulations', uuid];
|
||||
|
||||
const history = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<PolicySimulationRun[]>>(`/api/v1/policy/${uuid}/simulations`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (sampleSize?: number) =>
|
||||
api.post<ApiResponse<PolicySimulationRun>>(`/api/v1/policy/${uuid}/simulate`, {
|
||||
sample_size: sampleSize ?? 50,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'اجرای آزمایشی ناموفق بود'),
|
||||
});
|
||||
|
||||
return { runs: history.data?.data ?? [], loading: history.isLoading, run };
|
||||
}
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
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 { usePatientNoShows } from '../hooks/useCancellation';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
@@ -45,7 +42,7 @@ import {
|
||||
} from '../lib/patientForm';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'courses' | 'notes' | 'callcenter' | 'attach' | 'records';
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
|
||||
{ key: 'services', label: 'سرویسها', icon: (c) => <TabServices color={c} /> },
|
||||
@@ -53,8 +50,6 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
|
||||
{ key: 'appointments', label: 'نوبتها', icon: (c) => <TabCalendar color={c} /> },
|
||||
{ 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} /> },
|
||||
@@ -173,7 +168,6 @@ export default function PatientDetailPage() {
|
||||
const sessions = sessionsQ.data?.data ?? [];
|
||||
const hasDebt = sessions.some((s) => !s.is_paid);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const { noShows } = usePatientNoShows(uuid);
|
||||
|
||||
const nextAppointment = (appointmentsQ.data?.data ?? [])
|
||||
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
|
||||
@@ -191,7 +185,6 @@ export default function PatientDetailPage() {
|
||||
tags={(record as any)?.tags}
|
||||
nextAppointment={nextAppointment}
|
||||
hasDebt={hasDebt}
|
||||
noShows={noShows}
|
||||
onAddNote={() => setTab('notes')}
|
||||
/>
|
||||
|
||||
@@ -269,10 +262,6 @@ export default function PatientDetailPage() {
|
||||
<PaymentsTab q={sessionsQ} />
|
||||
) : tab === 'wallet' ? (
|
||||
<WalletTab uuid={uuid!} />
|
||||
) : tab === 'packages' ? (
|
||||
<PackagesTab uuid={uuid!} />
|
||||
) : tab === 'courses' ? (
|
||||
<CoursesTab uuid={uuid!} />
|
||||
) : tab === 'callcenter' ? (
|
||||
<CallCenterTab uuid={uuid!} />
|
||||
) : tab === 'attach' ? (
|
||||
@@ -1033,161 +1022,3 @@ function AppointmentsTab({ uuid, q }: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* پکیجهای بیمار.
|
||||
*
|
||||
* مانده از دفتر میآید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان میدهد تا
|
||||
* معلوم باشد عدد از کجا آمده.
|
||||
*/
|
||||
function PackagesTab({ uuid }: { uuid: string }) {
|
||||
const { patientPackages, loading, sell } = usePatientPackages(uuid);
|
||||
const { packages } = usePackages();
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
const active = packages.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.name} — ${p.session_count} جلسه`,
|
||||
}))}
|
||||
placeholder="انتخاب پکیج"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={selected === '' || sell.isPending}
|
||||
onClick={async () => {
|
||||
await sell.mutateAsync({ package_uuid: selected });
|
||||
setSelected('');
|
||||
}}
|
||||
>
|
||||
ثبت خرید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||||
) : patientPackages.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
این بیمار هنوز پکیجی نخریده است
|
||||
</div>
|
||||
) : (
|
||||
patientPackages.map((p) => (
|
||||
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
خرید {formatDate(p.purchased_at)}
|
||||
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 14 }}>
|
||||
مانده: <strong>{p.balance}</strong> از {p.session_count}
|
||||
</span>
|
||||
|
||||
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
|
||||
{!p.expired && p.balance === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه میشود.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
className="btn secondary sm"
|
||||
style={{ marginRight: 'auto' }}
|
||||
to={`/admin/patient-package/${p.uuid}/ledger`}
|
||||
>
|
||||
دفتر اعتبار
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1106,35 +1106,6 @@ export interface PolicyClause {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface PolicyCondition {
|
||||
match?: 'all' | 'any';
|
||||
conditions?: PolicyClause[];
|
||||
}
|
||||
|
||||
export interface PolicyEffect {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
uuid: string;
|
||||
category: PolicyCategory;
|
||||
name: string;
|
||||
condition: PolicyCondition;
|
||||
effects: PolicyEffect[];
|
||||
priority: number;
|
||||
version: number;
|
||||
active: boolean;
|
||||
valid_from: number | null;
|
||||
valid_to: number | null;
|
||||
address_uuid: string | null;
|
||||
service_uuid: string | null;
|
||||
catalog_category_uuid: string | null;
|
||||
specificity: number;
|
||||
versions?: PolicyVersionLog[];
|
||||
}
|
||||
|
||||
export interface PolicyVersionLog {
|
||||
version: number;
|
||||
snapshot: Record<string, unknown>;
|
||||
@@ -1175,13 +1146,6 @@ export interface PolicyTemplateInput {
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface PolicyTemplate {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PolicyCategory;
|
||||
inputs: PolicyTemplateInput[];
|
||||
}
|
||||
|
||||
export interface SimulationRow {
|
||||
appointment_uuid: string;
|
||||
@@ -1205,7 +1169,6 @@ export interface PolicySimulationRun {
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
// ── پکیج و اعتبار جلسات (تسک ۱۱) ─────────────────────────────────────────────
|
||||
|
||||
export interface PackageDefinition {
|
||||
uuid: string;
|
||||
@@ -1219,70 +1182,9 @@ export interface PackageDefinition {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PatientPackage {
|
||||
uuid: string;
|
||||
package_uuid: string;
|
||||
package_name: string;
|
||||
patient_uuid: string;
|
||||
session_count: number;
|
||||
price_paid_rials: number;
|
||||
purchased_at: number;
|
||||
valid_to: number | null;
|
||||
expired: boolean;
|
||||
/** همیشه از جمع دفتر میآید — هیچ ستونی در دیتابیس نیست */
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface CreditLedgerRow {
|
||||
uuid: string;
|
||||
kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry';
|
||||
delta: number;
|
||||
appointment_uuid: string | null;
|
||||
service_uuid: string | null;
|
||||
service_name: string | null;
|
||||
reason: string | null;
|
||||
created_by: string | null;
|
||||
created_at: number;
|
||||
/** در UI محاسبهشده نیست — سرور همان جمع تجمعی را میدهد */
|
||||
running_balance: number;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1295,29 +1197,6 @@ export interface CourseProgress {
|
||||
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;
|
||||
preferred_resource_name: string | null;
|
||||
package_balance?: number | null;
|
||||
package_shortfall?: number | 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>;
|
||||
@@ -1327,57 +1206,7 @@ export interface NextSlotSuggestion {
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
// ── لغو و لیست انتظار (تسک ۱۳) ───────────────────────────────────────────────
|
||||
|
||||
export interface CancellationPolicy {
|
||||
uuid: string;
|
||||
service_uuid: string | null;
|
||||
service_name: string | null;
|
||||
free_window_hours: number;
|
||||
penalty_mode: 'none' | 'percent' | 'fixed';
|
||||
penalty_value: number;
|
||||
deposit_refundable: boolean;
|
||||
credit_refundable: boolean;
|
||||
no_show_threshold: number;
|
||||
risk_tag_uuid: string | null;
|
||||
active: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
penalty_rials: number;
|
||||
deposit_refundable: boolean;
|
||||
credit_refundable: boolean;
|
||||
within_free_window: boolean;
|
||||
notes: string[];
|
||||
paid_rials: number;
|
||||
}
|
||||
|
||||
export interface CancellationResult extends Omit<CancellationPreview, 'paid_rials'> {
|
||||
appointment_uuid: string;
|
||||
status: string;
|
||||
released_resources: number;
|
||||
waitlist_notified: number;
|
||||
penalty_charged: boolean;
|
||||
}
|
||||
|
||||
export interface WaitlistEntry {
|
||||
uuid: string;
|
||||
patient_uuid: string;
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
branch_id: number | null;
|
||||
desired_from: number;
|
||||
desired_to: number;
|
||||
preferred_day_parts: string[];
|
||||
priority: number;
|
||||
status: 'waiting' | 'notified' | 'converted' | 'expired';
|
||||
notified_at: number | null;
|
||||
notify_count: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// ── گزارشها (تسک ۱۴) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UtilizationRow {
|
||||
resource_uuid: string;
|
||||
|
||||
@@ -23,11 +23,6 @@ framework:
|
||||
'App\Sms\Message\SendSmsMessage': async
|
||||
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
||||
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
||||
'App\Shared\Event\Message\PublishDomainEventsMessage': scheduler_default
|
||||
'App\Waitlist\Message\ExpireWaitlistMessage': scheduler_default
|
||||
# مصرفکنندهٔ رویداد async است، وگرنه یک consumer کند خودِ تخلیهٔ صندوق را
|
||||
# کند میکند و شکستش ردیفی را «ناموفق» علامت میزند که در واقع تحویل شده بود.
|
||||
'App\Shared\Event\Message\DomainEventMessage': async
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?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 Version20260801170043 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop the policy, package, treatment-course, cancellation, waitlist and domain-event tables. '
|
||||
. 'Those subsystems were removed from the product; the booking model keeps only resources, '
|
||||
. 'services and service options. down() recreates the schema but not the data.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ترتیب حذف مهم نیست چون کلیدهای خارجی موقتاً خاموش میشوند؛ و `IF EXISTS`
|
||||
// لازم است چون اجرای نیمهکارهٔ قبلی ممکن است بخشی را برداشته باشد و
|
||||
// migrationی که فقط یک بار کار کند، روی محیط نیمهمهاجرتکرده گیر میکند.
|
||||
$this->addSql('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
foreach ([
|
||||
'policy_simulation_runs',
|
||||
'policy_version_logs',
|
||||
'policies',
|
||||
'session_credit_ledger',
|
||||
'patient_packages',
|
||||
'package_services',
|
||||
'packages',
|
||||
'course_sessions',
|
||||
'course_protocol_steps',
|
||||
'course_protocols',
|
||||
'treatment_courses',
|
||||
'no_show_records',
|
||||
'cancellation_policies',
|
||||
'waitlist_entries',
|
||||
'domain_events',
|
||||
] as $table) {
|
||||
$this->addSql(sprintf('DROP TABLE IF EXISTS %s', $table));
|
||||
}
|
||||
|
||||
$this->addSql('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
// ستون نوبت که به جلسهٔ دوره اشاره میکرد.
|
||||
if ($schema->getTable('appointments')->hasColumn('course_session_id')) {
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE cancellation_policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, free_window_hours SMALLINT DEFAULT 24 NOT NULL, penalty_mode VARCHAR(10) CHARACTER SET utf8mb4 DEFAULT \'none\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, penalty_value INT DEFAULT 0 NOT NULL, deposit_refundable TINYINT DEFAULT 0 NOT NULL, credit_refundable TINYINT DEFAULT 1 NOT NULL, no_show_threshold SMALLINT DEFAULT 3 NOT NULL, risk_tag_uuid VARCHAR(36) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, service_item_id INT DEFAULT NULL, INDEX idx_cancel_policies_tenant (entity_type, entity_id, active), UNIQUE INDEX UNIQ_52BE2A1D17F50A6 (uuid), INDEX IDX_52BE2A1DDEB00C2 (service_item_id), UNIQUE INDEX uniq_cancel_policy_scope (entity_type, entity_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE course_protocols (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, 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) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, service_item_id INT NOT NULL, INDEX idx_protocols_tenant (entity_type, entity_id, active), UNIQUE INDEX UNIQ_E60D42A0D17F50A6 (uuid), UNIQUE INDEX uniq_protocol_service (service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$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, UNIQUE INDEX uniq_step (protocol_id, session_number), INDEX IDX_58A0726CCD59258 (protocol_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE course_sessions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_number SMALLINT NOT NULL, params JSON DEFAULT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'planned\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, course_id INT NOT NULL, appointment_id INT DEFAULT NULL, INDEX idx_sessions_tenant (entity_type, entity_id, status), UNIQUE INDEX uniq_session_appointment (appointment_id), UNIQUE INDEX UNIQ_33D4F045D17F50A6 (uuid), INDEX IDX_33D4F045591CC992 (course_id), UNIQUE INDEX uniq_course_session (course_id, session_number), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE domain_events (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(60) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, payload JSON NOT NULL, occurred_at INT NOT NULL, published_at INT DEFAULT NULL, attempts SMALLINT DEFAULT 0 NOT NULL, last_error VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, INDEX idx_de_name (name, occurred_at), INDEX idx_de_tenant (entity_type, entity_id, occurred_at), UNIQUE INDEX UNIQ_3CE45B83D17F50A6 (uuid), INDEX idx_de_pending (published_at, occurred_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE no_show_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, recorded_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_record_id INT NOT NULL, appointment_id INT NOT NULL, recorded_by INT DEFAULT NULL, INDEX idx_no_show_tenant (entity_type, entity_id, recorded_at), INDEX IDX_C37342082D4278B (recorded_by), UNIQUE INDEX UNIQ_C373420D17F50A6 (uuid), INDEX idx_no_show_patient (patient_record_id, recorded_at), UNIQUE INDEX uniq_no_show_appointment (appointment_id), INDEX IDX_C373420EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, price_rials BIGINT NOT NULL, validity_days SMALLINT DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_9BB5C0A7D17F50A6 (uuid), INDEX idx_packages_tenant (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE package_services (id INT AUTO_INCREMENT NOT NULL, package_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_97B37507F44CABFF (package_id), INDEX IDX_97B37507DDEB00C2 (service_item_id), UNIQUE INDEX uniq_pkg_service (package_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE patient_packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, price_paid_rials BIGINT NOT NULL, purchased_at INT NOT NULL, valid_to INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, package_id INT NOT NULL, patient_record_id INT NOT NULL, payment_id INT DEFAULT NULL, INDEX IDX_AC3098024C3A3BB (payment_id), UNIQUE INDEX UNIQ_AC309802D17F50A6 (uuid), INDEX idx_pp_patient (patient_record_id, valid_to), INDEX idx_pp_tenant (entity_type, entity_id, purchased_at), INDEX IDX_AC309802F44CABFF (package_id), INDEX IDX_AC309802EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, category VARCHAR(20) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, condition_json JSON NOT NULL, effects JSON NOT NULL, priority SMALLINT DEFAULT 0 NOT NULL, valid_from INT DEFAULT NULL, valid_to INT DEFAULT NULL, version SMALLINT DEFAULT 1 NOT NULL, active TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, address_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, catalog_category_id INT DEFAULT NULL, specificity SMALLINT DEFAULT 0 NOT NULL, INDEX IDX_E08BBFCE3F2BC4C (catalog_category_id), UNIQUE INDEX UNIQ_E08BBFCED17F50A6 (uuid), INDEX idx_policy_tenant_category (entity_type, entity_id, category, active), INDEX IDX_E08BBFCEF5B7AF75 (address_id), INDEX idx_policy_validity (valid_from, valid_to), INDEX IDX_E08BBFCEDDEB00C2 (service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE policy_simulation_runs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, policy_version SMALLINT NOT NULL, sample_size SMALLINT NOT NULL, affected_count SMALLINT NOT NULL, severity VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, report JSON NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, policy_id INT NOT NULL, run_by INT DEFAULT NULL, INDEX IDX_F993F7C82D29E3C6 (policy_id), INDEX IDX_F993F7C84114BD6 (run_by), INDEX idx_psr_tenant (entity_type, entity_id, created_at), INDEX idx_psr_policy (policy_id, policy_version, created_at), UNIQUE INDEX UNIQ_F993F7C8D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE policy_version_logs (id INT AUTO_INCREMENT NOT NULL, version SMALLINT NOT NULL, snapshot JSON NOT NULL, created_at INT NOT NULL, policy_id INT NOT NULL, INDEX IDX_B27F258B2D29E3C6 (policy_id), UNIQUE INDEX uniq_policy_version (policy_id, version), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE session_credit_ledger (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, kind VARCHAR(15) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, delta SMALLINT NOT NULL, reason VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, created_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_package_id INT NOT NULL, appointment_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, created_by INT DEFAULT NULL, INDEX idx_scl_package (patient_package_id, created_at), INDEX IDX_6B3C6BD6428C0D10 (patient_package_id), UNIQUE INDEX UNIQ_6B3C6BD6D17F50A6 (uuid), INDEX IDX_6B3C6BD6DDEB00C2 (service_item_id), UNIQUE INDEX uniq_scl_consume (appointment_id, kind), INDEX idx_scl_appt (appointment_id), INDEX idx_scl_tenant (entity_type, entity_id, created_at), INDEX IDX_6B3C6BD6DE12AB56 (created_by), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE treatment_courses (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, min_days SMALLINT NOT NULL, ideal_days SMALLINT NOT NULL, max_days SMALLINT NOT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'active\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, active_course_key VARCHAR(64) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, abandon_reason VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, started_at INT NOT NULL, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, 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, INDEX IDX_D1639880CB4D0F54 (preferred_resource_id), INDEX IDX_D1639880DDEB00C2 (service_item_id), UNIQUE INDEX UNIQ_D1639880D17F50A6 (uuid), INDEX idx_courses_tenant (entity_type, entity_id, status, started_at), INDEX IDX_D1639880CCD59258 (protocol_id), UNIQUE INDEX UNIQ_D1639880DEBE4F6D (active_course_key), INDEX idx_courses_patient (patient_record_id, status), INDEX IDX_D1639880428C0D10 (patient_package_id), INDEX IDX_D1639880EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE waitlist_entries (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, branch_id INT DEFAULT NULL, desired_from INT NOT NULL, desired_to INT NOT NULL, preferred_day_parts JSON DEFAULT NULL, priority SMALLINT DEFAULT 0 NOT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'waiting\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, notified_at INT DEFAULT NULL, notify_count SMALLINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, converted_appointment_id INT DEFAULT NULL, INDEX IDX_E74550EEDDEB00C2 (service_item_id), INDEX idx_waitlist_tenant (entity_type, entity_id, status, created_at), INDEX IDX_E74550EE4B79C8F8 (converted_appointment_id), UNIQUE INDEX UNIQ_E74550EED17F50A6 (uuid), INDEX idx_waitlist_patient (patient_record_id, status), INDEX idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to), INDEX IDX_E74550EEEB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('ALTER TABLE cancellation_policies ADD CONSTRAINT `FK_52BE2A1DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (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_protocol_steps ADD CONSTRAINT `FK_58A0726CCD59258` FOREIGN KEY (protocol_id) REFERENCES course_protocols (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 no_show_records ADD CONSTRAINT `FK_C37342082D4278B` FOREIGN KEY (recorded_by) REFERENCES users (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT `FK_C373420E5B533F9` FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT `FK_C373420EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE package_services ADD CONSTRAINT `FK_97B37507DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id)');
|
||||
$this->addSql('ALTER TABLE package_services ADD CONSTRAINT `FK_97B37507F44CABFF` FOREIGN KEY (package_id) REFERENCES packages (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC3098024C3A3BB` FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC309802EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id)');
|
||||
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC309802F44CABFF` FOREIGN KEY (package_id) REFERENCES packages (id)');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCE3F2BC4C` FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCEDDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCEF5B7AF75` FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policy_simulation_runs ADD CONSTRAINT `FK_F993F7C82D29E3C6` FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policy_simulation_runs ADD CONSTRAINT `FK_F993F7C84114BD6` FOREIGN KEY (run_by) REFERENCES users (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE policy_version_logs ADD CONSTRAINT `FK_B27F258B2D29E3C6` FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6428C0D10` FOREIGN KEY (patient_package_id) REFERENCES patient_packages (id)');
|
||||
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6DE12AB56` FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6E5B533F9` FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
|
||||
$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');
|
||||
$this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880CCD59258` FOREIGN KEY (protocol_id) REFERENCES course_protocols (id)');
|
||||
$this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id)');
|
||||
$this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id)');
|
||||
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EE4B79C8F8` FOREIGN KEY (converted_appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EEDDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EEEB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||
$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)');
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ class AvailabilityController extends BaseController
|
||||
private readonly WeeklyScheduleRepository $schedules,
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly \App\Course\Repository\TreatmentCourseRepository $courses,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])]
|
||||
@@ -111,7 +110,6 @@ class AvailabilityController extends BaseController
|
||||
$step,
|
||||
null,
|
||||
$this->strategyFor($data['doctor_uuid'] ?? null),
|
||||
$this->preferredResourceIds($user, $data['course_uuid'] ?? null),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
@@ -194,37 +192,6 @@ class AvailabilityController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* منبع ترجیحیِ یک دورهٔ درمان — «همان اپراتور جلسهٔ قبل».
|
||||
*
|
||||
* ترجیح است نه فیلتر: اگر آزاد نباشد، استراتژی به ترتیب پایه برمیگردد و رزرو
|
||||
* انجام میشود. اجبار یعنی بیمار دو هفته منتظر بماند.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function preferredResourceIds(User $user, mixed $courseUuid): array
|
||||
{
|
||||
if (!is_string($courseUuid) || $courseUuid === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$course = $this->courses->findByUuid($courseUuid);
|
||||
|
||||
if ($course === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($course->getEntityType() !== $entityType || $course->getEntityId() !== $entityId) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
|
||||
}
|
||||
|
||||
$preferred = $course->getPreferredResource();
|
||||
|
||||
return $preferred === null ? [] : [(int) $preferred->getId()];
|
||||
}
|
||||
|
||||
private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void
|
||||
{
|
||||
if (!is_string($doctorUuid) || $doctorUuid === '') {
|
||||
|
||||
@@ -10,8 +10,6 @@ use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -45,7 +43,6 @@ class ResourceBlockController extends BaseController
|
||||
private readonly ResourceOccupancyRepository $occupancy,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly DomainEventPublisher $domainEvents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -99,17 +96,6 @@ class ResourceBlockController extends BaseController
|
||||
|
||||
$this->em->persist($block);
|
||||
|
||||
$this->domainEvents->record(
|
||||
$resource->getEntityType(),
|
||||
$resource->getEntityId(),
|
||||
DomainEvents::RESOURCE_BLOCKED,
|
||||
[
|
||||
'resource_uuid' => $resource->getUuid(),
|
||||
'block_uuid' => $block->getUuid(),
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
],
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -136,18 +122,6 @@ class ResourceBlockController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
// پیش از `remove` ثبت میشود چون بعد از آن، uuid و بازه فقط در حافظهاند و
|
||||
// خواندنشان از یک entity حذفشده به رفتار Doctrine وابسته میماند.
|
||||
$this->domainEvents->record(
|
||||
$entityType,
|
||||
$entityId,
|
||||
DomainEvents::RESOURCE_RELEASED,
|
||||
[
|
||||
'block_uuid' => $block->getUuid(),
|
||||
'starts_at' => $block->getStartsAt(),
|
||||
'ends_at' => $block->getEndsAt(),
|
||||
],
|
||||
);
|
||||
|
||||
$this->em->remove($block);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -13,8 +13,6 @@ use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Pricing\Entity\PriceSnapshot;
|
||||
use App\Pricing\Service\PriceSnapshotService;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Policy\Service\BookingPolicyGuard;
|
||||
use App\Pricing\Service\PricingEngine;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
@@ -25,8 +23,6 @@ use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -56,38 +52,11 @@ class BookingController extends BaseController
|
||||
private readonly PricingEngine $pricing,
|
||||
private readonly PriceSnapshotService $snapshots,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly BookingPolicyGuard $guard,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly AppointmentSegmentRepository $segments,
|
||||
private readonly DomainEventPublisher $domainEvents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* پرچمهایی که فقط در همین درخواست وجود دارند و جایی ذخیره نمیشوند
|
||||
* (مثل رضایت والدین که اپراتور همان لحظه میگیرد).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestFlags(array $data): array
|
||||
{
|
||||
$flags = [];
|
||||
|
||||
foreach (['has_parental_consent'] as $flag) {
|
||||
if (isset($data[$flag])) {
|
||||
$flags[$flag] = (bool) $data[$flag];
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($data['patient_gender'] ?? null)) {
|
||||
$flags['patient_gender'] = $data['patient_gender'];
|
||||
}
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
@@ -128,9 +97,6 @@ class BookingController extends BaseController
|
||||
is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null,
|
||||
);
|
||||
|
||||
// قوانین وابسته به بیمار پیش از گرفتن صندلی اجرا میشوند، نه هنگام ثبت نهایی.
|
||||
$this->guard->assertEligible($user, $service, $selected, $address, $this->requestFlags($data));
|
||||
$this->guard->assertSpacing($user, $service, $address, (int) $data['start']);
|
||||
|
||||
$assignment = $this->resolveAssignment($user, $data['assignment']);
|
||||
$this->assertAssignmentCoversPlan($plan, $assignment);
|
||||
@@ -255,19 +221,6 @@ class BookingController extends BaseController
|
||||
$this->booking->confirm($hold, $appointment);
|
||||
$released = $this->booking->cancel($appointment);
|
||||
|
||||
// `confirm` و `cancel` هرکدام رویداد خودشان را ثبت کردهاند؛ این سومی میگوید آن دو
|
||||
// یک جابهجایی بودهاند نه یک لغو و یک رزروِ بیربط. مصرفکنندهای که فقط
|
||||
// `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
||||
$this->domainEvents->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_RESCHEDULED,
|
||||
[
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'previous_start' => $previousStart,
|
||||
'new_start' => $hold->getStartsAt(),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
@@ -305,8 +258,6 @@ class BookingController extends BaseController
|
||||
$address,
|
||||
$hold->getStartsAt(),
|
||||
is_array($data['policy'] ?? null) ? $data['policy'] : [],
|
||||
// پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد.
|
||||
$this->packages->patientRecordFor($appointment),
|
||||
);
|
||||
|
||||
return $this->snapshots->record($appointment, $quote);
|
||||
|
||||
@@ -6,12 +6,7 @@ use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
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\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
@@ -26,10 +21,6 @@ final class BookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HoldService $holds,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly CreditLedgerService $credits,
|
||||
private readonly CourseSessionLinker $courseSessions,
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -61,21 +52,9 @@ final class BookingService
|
||||
$this->writeSegments($hold, $appointment);
|
||||
$hold->markConfirmed($now);
|
||||
|
||||
// رویداد در همان flushِ ثبت نوبت میرود؛ اگر این تراکنش برگردد، رویدادی هم
|
||||
// نمیماند که کسی به آن واکنش نشان دهد.
|
||||
$this->events->record(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_BOOKED,
|
||||
['appointment_uuid' => $appointment->getUuid(), 'hold_uuid' => $hold->getUuid()],
|
||||
$now,
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
// مصرف اعتبار **اینجا**ست نه در پیشنمایش قیمت: تنها لحظهای که نوبت واقعاً
|
||||
// وجود دارد. کلید یکتای دفتر هم تضمین میکند اجرای دوباره جلسهٔ دوم نخورد.
|
||||
$this->packages->consumeFor($appointment);
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
@@ -119,18 +98,9 @@ final class BookingService
|
||||
|
||||
$this->holds->release($occupancies);
|
||||
|
||||
// ردیف `consume` **حذف نمیشود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
|
||||
$this->credits->refund($appointment);
|
||||
|
||||
// جلسهٔ دوره به `planned` برمیگردد؛ بقیهٔ جلسات دستنخورده میمانند.
|
||||
$this->courseSessions->unlink($appointment);
|
||||
|
||||
$this->events->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_CANCELLED,
|
||||
['appointment_uuid' => $appointment->getUuid(), 'released_resources' => count($occupancies)],
|
||||
);
|
||||
$this->em->flush();
|
||||
|
||||
return count($occupancies);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ use App\Appointment\Plan\ValueObject\AppointmentPlan;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -36,7 +34,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class HoldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -98,15 +95,6 @@ final class HoldService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// بعد از اینکه **همهٔ** منابع گرفته شدند، نه پیش از آن: رزروی که وسط کار
|
||||
// شکسته، رویدادی هم ندارد.
|
||||
$this->events->recordAndFlush(
|
||||
$entityType,
|
||||
$entityId,
|
||||
DomainEvents::HOLD_CREATED,
|
||||
['hold_uuid' => $hold->getUuid(), 'starts_at' => $startsAt, 'resources' => count($taken)],
|
||||
$now,
|
||||
);
|
||||
|
||||
return $hold;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
||||
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
||||
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
||||
private readonly \App\Shared\Event\DomainEventPublisher $domainEvents,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -73,27 +72,6 @@ class AppointmentController extends BaseController
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت رویداد دامنهٔ «نوبت انجام شد».
|
||||
*
|
||||
* جدا از `AppointmentEvent` است و جایگزینش نمیشود: آن، تایملاینِ خواندهشده توسط
|
||||
* اپراتور است و این، صندوق خروجی برای مصرفکنندههای بیرونی. هر دو مسیرِ تغییر
|
||||
* وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا میرسند، چون
|
||||
* رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
|
||||
*/
|
||||
private function recordCompletion(Appointment $appointment): void
|
||||
{
|
||||
$this->domainEvents->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
\App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
|
||||
[
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'slot_start' => $appointment->getSlotStart(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
@@ -978,7 +956,6 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_COMPLETED) {
|
||||
$this->recordCompletion($appointment);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
@@ -1254,7 +1231,6 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if ($completed) {
|
||||
$this->recordCompletion($appointment);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
|
||||
@@ -225,13 +225,6 @@ 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;
|
||||
@@ -295,8 +288,6 @@ class Appointment
|
||||
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; }
|
||||
|
||||
@@ -16,10 +16,6 @@ use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Policy\Engine\ResourcePolicyEngine;
|
||||
use App\Policy\Engine\TimingPolicyEngine;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
@@ -31,8 +27,6 @@ use App\Shared\Exception\AppException;
|
||||
final class AppointmentPlanBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimingPolicyEngine $timingPolicies,
|
||||
private readonly ResourcePolicyEngine $resourcePolicies,
|
||||
private readonly SegmentTemplateRepository $templates,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
@@ -111,14 +105,6 @@ final class AppointmentPlanBuilder
|
||||
array $segments,
|
||||
int $total,
|
||||
): AppointmentPlan {
|
||||
// ── قوانین دستهٔ «زمان» ────────────────────────────────────────────
|
||||
// اثرها روی **مجموع** نوبت اعمال میشوند نه روی یک بخش: «حداقل ۶۰ دقیقه»
|
||||
// یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد.
|
||||
$total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total);
|
||||
|
||||
// ── قوانین دستهٔ «منبع» ─────────────────────────────────────────────
|
||||
$segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments);
|
||||
|
||||
if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
@@ -131,61 +117,6 @@ final class AppointmentPlanBuilder
|
||||
return new AppointmentPlan($segments, $total);
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع).
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments بهصورت ارجاع تغییر میکند
|
||||
*/
|
||||
private function applyTimingPolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array &$segments,
|
||||
int $total,
|
||||
): int {
|
||||
$outcome = $this->timingPolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->effects === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
$extra = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0);
|
||||
$minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0);
|
||||
$target = max($total + $extra, $minimum);
|
||||
|
||||
if ($target === $total || $segments === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
// مدت اضافه به **آخرین** بخش میرود: آفست بخشهای قبلی نباید عوض شود، وگرنه
|
||||
// برنامهای که کاربر تأیید کرده زیر پایش جابهجا میشود.
|
||||
$last = $segments[count($segments) - 1];
|
||||
$grown = $last->durationMinutes + ($target - $total);
|
||||
|
||||
$segments[count($segments) - 1] = new PlannedSegment(
|
||||
sequence: $last->sequence,
|
||||
name: $last->name,
|
||||
offsetMinutes: $last->offsetMinutes,
|
||||
durationMinutes: $grown,
|
||||
patientPresent: $last->patientPresent,
|
||||
mergeable: $last->mergeable,
|
||||
requirements: $last->requirements,
|
||||
);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* الگوهای سرویس اصلی **بهعلاوهٔ** الگوهای آیتمهای انتخابشده.
|
||||
*
|
||||
@@ -291,158 +222,10 @@ final class AppointmentPlanBuilder
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «منبع»: نقشی که قانون لازم میداند، اگر الگو نداشته باشد، اضافه میشود.
|
||||
*
|
||||
* نقشِ اضافهشده به **اولین بخشی که بیمار حاضر است** میچسبد، نه به همهٔ بخشها:
|
||||
* «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ
|
||||
* آمادهسازی هم اشغال شود.
|
||||
*
|
||||
* ممنوعیت هم اینجا خوانده میشود: قانونی که میگوید این ترکیب در این شعبه انجام
|
||||
* نمیشود، پیش از رسیدن به موتور دسترسپذیری جلوی کار را میگیرد.
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments
|
||||
* @return list<PlannedSegment>
|
||||
*/
|
||||
private function applyResourcePolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array $segments,
|
||||
): array {
|
||||
if ($segments === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$outcome = $this->resourcePolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->isForbidden()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
implode(' ', $outcome->forbidReasons),
|
||||
422,
|
||||
'service_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
$required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []);
|
||||
|
||||
if ($required === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$present = [];
|
||||
foreach ($segments as $segment) {
|
||||
foreach ($segment->requirements as $requirement) {
|
||||
$present[$requirement->role] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$targetIndex = $this->firstPatientPresentIndex($segments);
|
||||
$extra = [];
|
||||
|
||||
foreach ($required as $code) {
|
||||
if (!is_string($code) || isset($present[$code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies);
|
||||
}
|
||||
|
||||
if ($extra === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$target = $segments[$targetIndex];
|
||||
|
||||
$segments[$targetIndex] = new PlannedSegment(
|
||||
sequence: $target->sequence,
|
||||
name: $target->name,
|
||||
offsetMinutes: $target->offsetMinutes,
|
||||
durationMinutes: $target->durationMinutes,
|
||||
patientPresent: $target->patientPresent,
|
||||
mergeable: $target->mergeable,
|
||||
requirements: [...$target->requirements, ...$extra],
|
||||
);
|
||||
|
||||
return array_values($segments);
|
||||
}
|
||||
|
||||
/** @param list<PlannedSegment> $segments */
|
||||
private function firstPatientPresentIndex(array $segments): int
|
||||
{
|
||||
foreach ($segments as $index => $segment) {
|
||||
if ($segment->patientPresent) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که نقشِ ناشناخته یا بیمنبع میخواهد **خطاست، نه بیاثر**: در سکوت رد
|
||||
* کردنش یعنی کلینیک فکر کند قانونش اجرا میشود در حالی که هیچوقت نشده.
|
||||
*/
|
||||
/**
|
||||
* @param list<array<string, mixed>> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون
|
||||
*/
|
||||
private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement
|
||||
{
|
||||
// بدون نام قانون، اپراتور میداند چه چیزی کم است ولی نه چرا لازم شده — و بین ده
|
||||
// قانون فعال باید حدس بزند کدام را خاموش کند.
|
||||
$names = array_values(array_filter(array_map(
|
||||
static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null,
|
||||
$appliedPolicies,
|
||||
)));
|
||||
$because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names));
|
||||
|
||||
$type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);
|
||||
|
||||
if ($type === null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
$eligible = array_values($this->resources->findEligible($address, $type, []));
|
||||
|
||||
if ($eligible === []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
|
||||
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
return new PlannedRequirement(
|
||||
role: $type->getCode(),
|
||||
roleName: $type->getName(),
|
||||
count: 1,
|
||||
occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
|
||||
constraints: [],
|
||||
eligible: $eligible,
|
||||
skillName: null,
|
||||
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
|
||||
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<PlannedRequirement> */
|
||||
private function planRequirements(
|
||||
SegmentTemplate $template,
|
||||
|
||||
@@ -9,8 +9,6 @@ use App\ClinicService\Entity\ServiceItemRelation;
|
||||
use App\ClinicService\Repository\ItemGroupMemberRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRelationRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Engine\SelectionPolicyEngine;
|
||||
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
|
||||
|
||||
/**
|
||||
@@ -30,7 +28,6 @@ final class ServiceSelectionValidator
|
||||
private readonly ServiceItemRelationRepository $relations,
|
||||
private readonly ServiceBranchOverrideRepository $overrides,
|
||||
private readonly DurationCalculator $durations,
|
||||
private readonly SelectionPolicyEngine $selectionPolicies,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -43,7 +40,6 @@ final class ServiceSelectionValidator
|
||||
$errors = [
|
||||
...$this->groupErrors($selected, $groups),
|
||||
...$this->relationErrors($selected),
|
||||
...$this->policyErrors($selected, $address),
|
||||
];
|
||||
|
||||
$overrides = $address === null
|
||||
@@ -62,52 +58,6 @@ final class ServiceSelectionValidator
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ممنوعیتهای دستهٔ «انتخاب» — لایهای روی گروه و رابطه، نه جایگزینشان.
|
||||
*
|
||||
* گروه و رابطه ساختار ثابتِ کاتالوگاند؛ قانون چیزی است که کلینیک بدون دست زدن
|
||||
* به کاتالوگ روشن و خاموش میکند. بدون شعبه اجرا نمیشود چون محیط از آدرس
|
||||
* میآید و بیآن هیچ محیطی برای جستوجو نیست.
|
||||
*
|
||||
* @param ServiceItem[] $selected
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function policyErrors(array $selected, ?DoctorAddress $address): array
|
||||
{
|
||||
if ($address === null || $selected === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$facts = [
|
||||
'item_count' => count($selected),
|
||||
'item_uuids' => array_map(static fn (ServiceItem $i): string => $i->getUuid(), $selected),
|
||||
];
|
||||
|
||||
$errors = [];
|
||||
|
||||
// هر آیتم جداگانه حل میشود: قانونی که دامنهاش یک سرویس خاص است فقط وقتی
|
||||
// معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام.
|
||||
foreach ($selected as $item) {
|
||||
$outcome = $this->selectionPolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
$facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()],
|
||||
$address,
|
||||
$item,
|
||||
);
|
||||
|
||||
foreach ($outcome->forbidReasons as $reason) {
|
||||
$errors[] = [
|
||||
'code' => 'policy_forbidden',
|
||||
'items' => [$item->getUuid()],
|
||||
'message' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServiceItem[] $selected
|
||||
* @param ItemGroup[] $groups
|
||||
|
||||
@@ -36,7 +36,6 @@ class PricingController extends BaseController
|
||||
private readonly PriceSnapshotRepository $snapshots,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly PricingEngine $engine,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
@@ -212,27 +211,7 @@ class PricingController extends BaseController
|
||||
$at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time();
|
||||
$policy = is_array($data['policy'] ?? null) ? $data['policy'] : [];
|
||||
|
||||
// بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است.
|
||||
$patient = is_string($data['patient_uuid'] ?? null)
|
||||
? $this->requirePatient($user, $data['patient_uuid'])
|
||||
: null;
|
||||
|
||||
return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray());
|
||||
}
|
||||
|
||||
private function requirePatient(User $user, string $uuid): \App\Patient\Entity\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;
|
||||
return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,14 +6,10 @@ use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
|
||||
use App\ClinicService\Repository\TariffRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Pricing\Repository\PriceListItemRepository;
|
||||
use App\Pricing\Repository\PriceListRepository;
|
||||
use App\Pricing\ValueObject\PriceQuote;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Engine\PricingPolicyEngine;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
|
||||
/**
|
||||
@@ -38,8 +34,6 @@ use App\Representation\Service\JalaliDateService;
|
||||
final class PricingEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PricingPolicyEngine $pricingPolicies,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly PriceListRepository $priceLists,
|
||||
private readonly PriceListItemRepository $priceListItems,
|
||||
private readonly ServiceBranchOverrideRepository $overrides,
|
||||
@@ -62,7 +56,6 @@ final class PricingEngine
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
array $policy = [],
|
||||
?PatientRecord $patient = null,
|
||||
): PriceQuote {
|
||||
$entityType = $address->tenantEntityType();
|
||||
$entityId = $address->tenantEntityId();
|
||||
@@ -80,22 +73,6 @@ final class PricingEngine
|
||||
|
||||
$subtotal = $base + $itemsTotal;
|
||||
|
||||
// ── پکیج ──────────────────────────────────────────────────────────────
|
||||
// پکیج **قیمت پایهٔ سرویس** را میپوشاند، نه آیتمهای اضافه: «شش جلسه لیزر»
|
||||
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
|
||||
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
|
||||
$covered = 0;
|
||||
|
||||
if ($usable !== null) {
|
||||
$covered = min($base, $subtotal);
|
||||
$subtotal -= $covered;
|
||||
}
|
||||
|
||||
// ── تخفیف ─────────────────────────────────────────────────────────────
|
||||
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست مینشینند، نه بهجایش: تخفیفی
|
||||
// که اپراتور دستی میدهد و تخفیفی که قانون میدهد هر دو واقعیاند.
|
||||
$policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources);
|
||||
|
||||
[$discount, $discounts] = $this->discountFor($subtotal, $policy);
|
||||
|
||||
// تخفیف بیشتر از مبلغ، مبلغ را **صفر** میکند نه منفی: بدهی منفی یعنی کلینیک
|
||||
@@ -125,13 +102,6 @@ final class PricingEngine
|
||||
|
||||
$deposit = max(0, min($deposit, $final));
|
||||
|
||||
if ($covered > 0) {
|
||||
$discounts[] = [
|
||||
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
|
||||
'rials' => $covered,
|
||||
'kind' => 'package',
|
||||
];
|
||||
}
|
||||
|
||||
return new PriceQuote(
|
||||
baseRials: $base,
|
||||
@@ -144,61 +114,9 @@ final class PricingEngine
|
||||
depositRials: $deposit,
|
||||
discounts: $discounts,
|
||||
sources: $sources,
|
||||
packageWillBeConsumed: $usable !== null,
|
||||
packageUuid: $usable?->getUuid(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* اثر قوانین «قیمت» را به سیاست درخواست اضافه میکند.
|
||||
*
|
||||
* شناسه و **نسخهٔ** هر قانون در `sources` ثبت میشود تا فاکتور بتواند سه ماه بعد
|
||||
* بگوید کدام نسخه رویش اعمال شده بود.
|
||||
*
|
||||
* @param ServiceItem[] $items
|
||||
* @param array<string, mixed> $policy
|
||||
* @param array<string, mixed> $sources
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergePolicyDiscounts(
|
||||
ServiceItem $service,
|
||||
array $items,
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
int $subtotal,
|
||||
array $policy,
|
||||
array &$sources,
|
||||
): array {
|
||||
$outcome = $this->pricingPolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'item_count' => count($items),
|
||||
'subtotal_rials' => $subtotal,
|
||||
'patient_tags' => $policy['patient_tags'] ?? [],
|
||||
'visit_count' => $policy['visit_count'] ?? 0,
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
$at,
|
||||
);
|
||||
|
||||
if ($outcome->appliedPolicies === []) {
|
||||
return $policy;
|
||||
}
|
||||
|
||||
$sources['applied_policies'] = $outcome->appliedPolicies;
|
||||
|
||||
$policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0)
|
||||
+ (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0);
|
||||
|
||||
$policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0)
|
||||
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
|
||||
|
||||
$policy['discount_label'] ??= $outcome->appliedPolicies[0]['name'];
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $sources
|
||||
|
||||
@@ -22,12 +22,6 @@ final readonly class PriceQuote
|
||||
public int $depositRials,
|
||||
public array $discounts = [],
|
||||
public array $sources = [],
|
||||
/**
|
||||
* پکیج در پیشنمایش **مصرف نمیشود** — فقط اعلام میشود. مصرف واقعی هنگام
|
||||
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار میگرفت.
|
||||
*/
|
||||
public bool $packageWillBeConsumed = false,
|
||||
public ?string $packageUuid = null,
|
||||
) {}
|
||||
|
||||
public function breakdown(): array
|
||||
@@ -46,8 +40,6 @@ final readonly class PriceQuote
|
||||
'tax_rials' => $this->taxRials,
|
||||
'final_rials' => $this->finalRials,
|
||||
'deposit_rials' => $this->depositRials,
|
||||
'package_will_be_consumed' => $this->packageWillBeConsumed,
|
||||
'package_uuid' => $this->packageUuid,
|
||||
'breakdown' => $this->breakdown(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ namespace App;
|
||||
|
||||
use App\Appointment\Message\ExpireAppointmentsMessage;
|
||||
use App\Blog\Message\PublishScheduledBlogsMessage;
|
||||
use App\Shared\Event\Message\PublishDomainEventsMessage;
|
||||
use App\Shared\Logging\Message\PruneLogsMessage;
|
||||
use App\Waitlist\Message\ExpireWaitlistMessage;
|
||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||
use Symfony\Component\Scheduler\RecurringMessage;
|
||||
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||
@@ -34,16 +32,6 @@ class Schedule implements ScheduleProviderInterface
|
||||
)
|
||||
->add(
|
||||
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
|
||||
)
|
||||
// صندوق خروجی رویدادها. `stateful` بالا یعنی تیکِ ازدسترفته بعد از ریاستارت
|
||||
// جبران میشود، و چون خودِ publisher از جدول میخواند، یک اجرا کافی است تا
|
||||
// هرچه در فاصله جمع شده برود.
|
||||
->add(
|
||||
RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
|
||||
)
|
||||
// انتظارهای مرده — پاکسازیِ نمایش، نه اصلاح رفتار (تطبیق از قبل ردشان میکرد).
|
||||
->add(
|
||||
RecurringMessage::every('1 day', new ExpireWaitlistMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,14 @@ use App\Appointment\Plan\Entity\SegmentRequirement;
|
||||
use App\Appointment\Plan\Entity\SegmentTemplate;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Cancellation\Entity\CancellationPolicy;
|
||||
use App\Cancellation\Entity\NoShowRecord;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\ItemGroup;
|
||||
use App\ClinicService\Entity\ItemGroupMember;
|
||||
use App\ClinicService\Entity\ServiceBranchOverride;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceItemRelation;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseProtocolStep;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Entity\Package;
|
||||
use App\Package\Entity\PackageService;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Pricing\Entity\PriceList;
|
||||
use App\Pricing\Entity\PriceListItem;
|
||||
use App\Pricing\Service\PriceSnapshotService;
|
||||
@@ -40,7 +29,6 @@ use App\Resource\Entity\ResourcePoolMember;
|
||||
use App\Resource\Entity\ResourceSkill;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Entity\Skill;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
@@ -64,7 +52,6 @@ final class BookingEngineSeeder
|
||||
private readonly HoldService $holds,
|
||||
private readonly BookingService $booking,
|
||||
private readonly PriceSnapshotService $snapshots,
|
||||
private readonly CreditLedgerService $credits,
|
||||
) {}
|
||||
|
||||
private function em(): \Doctrine\ORM\EntityManagerInterface
|
||||
@@ -101,10 +88,7 @@ final class BookingEngineSeeder
|
||||
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
|
||||
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
|
||||
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
|
||||
$counts['policies'] = $this->policies($entityType, $entityId, $services);
|
||||
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
|
||||
$counts['packages'] = $this->packagesAndCourses($entityType, $entityId, $services, $patients);
|
||||
$counts['aftercare'] = $this->cancellationAndWaitlist($entityType, $entityId, $services, $patients, $doctor);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
@@ -258,50 +242,6 @@ final class BookingEngineSeeder
|
||||
|
||||
// ── تسک ۰۹: سیاستها، یکی از هر دسته ────────────────────────────────────
|
||||
|
||||
private function policies(string $entityType, int $entityId, array $services): int
|
||||
{
|
||||
$service = $services[0];
|
||||
|
||||
$rows = [
|
||||
[Policy::CATEGORY_SELECTION, 'حداکثر سه ناحیه در یک نوبت',
|
||||
[['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]],
|
||||
[['type' => 'forbid']]],
|
||||
|
||||
[Policy::CATEGORY_ELIGIBILITY, 'زیر ۱۸ سال بدون رضایت والدین ممنوع',
|
||||
[['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]],
|
||||
[['type' => 'require_flag', 'value' => 'parental_consent']]],
|
||||
|
||||
[Policy::CATEGORY_RESOURCE, 'لیزر بدنِ کامل اپراتور ارشد میخواهد',
|
||||
[['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]],
|
||||
[['type' => 'require_resource', 'value' => 'laser']]],
|
||||
|
||||
[Policy::CATEGORY_TIMING, 'سبد بزرگ ۱۵ دقیقه وقت بیشتر میگیرد',
|
||||
[['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 3]],
|
||||
[['type' => 'add_duration_minutes', 'value' => 15]]],
|
||||
|
||||
[Policy::CATEGORY_SPACING, 'حداقل ۲۸ روز فاصله بین دو جلسهٔ لیزر',
|
||||
[['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]],
|
||||
[['type' => 'min_days_between', 'value' => 28]]],
|
||||
|
||||
[Policy::CATEGORY_PRICING, 'تخفیف ۱۰٪ برای سبد بالای ۲۰ میلیون ریال',
|
||||
[['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 20_000_000]],
|
||||
[['type' => 'discount_percent', 'value' => 10]]],
|
||||
];
|
||||
|
||||
foreach ($rows as $i => [$category, $name, $conditions, $effects]) {
|
||||
$policy = new Policy($entityType, $entityId, $category, $name);
|
||||
$policy->setCondition(['match' => 'all', 'conditions' => $conditions])
|
||||
->setEffects($effects)
|
||||
->setPriority(10 * ($i + 1))
|
||||
->setActive(true);
|
||||
$this->em()->persist($policy);
|
||||
}
|
||||
|
||||
$this->em()->flush();
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
// ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ─────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -374,130 +314,6 @@ final class BookingEngineSeeder
|
||||
|
||||
// ── تسک ۱۱ و ۱۲: پکیج، دفتر اعتبار، دورهٔ درمان ─────────────────────────
|
||||
|
||||
private function packagesAndCourses(string $entityType, int $entityId, array $services, array $patients): int
|
||||
{
|
||||
$service = $services[0];
|
||||
|
||||
$package = new Package($entityType, $entityId, 'پکیج ۶ جلسه لیزر', 6);
|
||||
$package->setPriceRials(60_000_000)->setValidityDays(365)->setActive(true);
|
||||
$this->em()->persist($package);
|
||||
$this->em()->persist(new PackageService($package, $service));
|
||||
$this->em()->flush();
|
||||
|
||||
$records = $this->em()->getRepository(PatientRecord::class)
|
||||
->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 3);
|
||||
|
||||
$made = 0;
|
||||
foreach ($records as $i => $record) {
|
||||
if ($i >= 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
$patientPackage = new PatientPackage($package, $record);
|
||||
$this->em()->persist($patientPackage);
|
||||
$this->em()->flush();
|
||||
|
||||
// یک جلسه مصرفشده تا دفتر خالی نباشد؛ دفتر append-only است، پس مصرف هم
|
||||
// یک ردیف است نه کمکردن یک عدد.
|
||||
$this->credits->record($patientPackage, SessionCreditLedger::KIND_CONSUME, -1, null, $service, 'مصرف جلسهٔ اول');
|
||||
$made++;
|
||||
}
|
||||
|
||||
// دورهٔ درمان: پروتکل با پارامتر هر جلسه (انرژی لیزر بالا میرود) و یک دورهٔ فعال.
|
||||
$protocol = new CourseProtocol($service, 6, 21, 28, 45);
|
||||
$this->em()->persist($protocol);
|
||||
foreach (range(1, 6) as $n) {
|
||||
$this->em()->persist(new CourseProtocolStep($protocol, $n, ['energy' => 10 + $n * 2, 'spot_size' => 18]));
|
||||
}
|
||||
$this->em()->flush();
|
||||
|
||||
if ($records !== []) {
|
||||
$course = new TreatmentCourse($records[0], $protocol);
|
||||
$this->em()->persist($course);
|
||||
|
||||
// جلسههای دوره از روی گامهای پروتکل ساخته میشوند. بدون این ردیفها دوره
|
||||
// «۶ جلسهای» است ولی هیچ جلسهای ندارد، و پیشنهاد جلسهٔ بعد میگوید همهچیز
|
||||
// برنامهریزی شده — یعنی دقیقاً برعکس واقعیت.
|
||||
foreach ($protocol->getSteps() as $step) {
|
||||
$this->em()->persist(new \App\Course\Entity\CourseSession(
|
||||
$course,
|
||||
$step->getSessionNumber(),
|
||||
$step->getParams(),
|
||||
));
|
||||
}
|
||||
|
||||
$this->em()->flush();
|
||||
$made++;
|
||||
}
|
||||
|
||||
return $made;
|
||||
}
|
||||
|
||||
// ── تسک ۱۳: لغو، عدم حضور، لیست انتظار ──────────────────────────────────
|
||||
|
||||
private function cancellationAndWaitlist(string $entityType, int $entityId, array $services, array $patients, \App\Doctor\Entity\Doctor $doctor): int
|
||||
{
|
||||
// سیاست عمومیِ محیط + یک سیاست سختگیرانهتر روی سرویس گران — تا انتخابِ
|
||||
// «اختصاصیترین سیاست» چیزی برای انتخاب داشته باشد.
|
||||
$general = new CancellationPolicy($entityType, $entityId);
|
||||
$general->setFreeWindowHours(24)->setPenalty(CancellationPolicy::MODE_PERCENT, 30)
|
||||
->setDepositRefundable(true)->setCreditRefundable(true)->setNoShowThreshold(3)->setActive(true);
|
||||
$this->em()->persist($general);
|
||||
|
||||
$strict = new CancellationPolicy($entityType, $entityId, $services[0]);
|
||||
$strict->setFreeWindowHours(48)->setPenalty(CancellationPolicy::MODE_PERCENT, 50)
|
||||
->setDepositRefundable(false)->setCreditRefundable(false)->setNoShowThreshold(2)->setActive(true);
|
||||
$this->em()->persist($strict);
|
||||
$this->em()->flush();
|
||||
|
||||
$records = $this->em()->getRepository(PatientRecord::class)
|
||||
->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 4);
|
||||
|
||||
// عدم حضورِ ثبتشده روی نوبتهایی که وضعیتشان no_show است — بدون این ردیف،
|
||||
// شمارندهٔ عدم حضور بیمار همیشه صفر میماند و آستانهٔ سیاست هرگز فعال نمیشود.
|
||||
$noShows = $this->em()->getRepository(Appointment::class)
|
||||
->findBy(['doctor' => $doctor, 'status' => Appointment::STATUS_NO_SHOW], null, 2);
|
||||
|
||||
foreach ($noShows as $appointment) {
|
||||
$record = $this->recordFor($records, $appointment->getUser());
|
||||
if ($record !== null) {
|
||||
$this->em()->persist(new NoShowRecord($record, $appointment));
|
||||
}
|
||||
}
|
||||
|
||||
$made = 2 + count($noShows);
|
||||
|
||||
// لیست انتظار: دو نفر منتظرِ بازهٔ هفتهٔ آینده، یکی با ترجیح صبح.
|
||||
foreach (array_slice($records, 0, 2) as $i => $record) {
|
||||
$entry = new WaitlistEntry(
|
||||
$record,
|
||||
$services[min($i, count($services) - 1)],
|
||||
strtotime('+2 days 00:00'),
|
||||
strtotime('+9 days 00:00'),
|
||||
$this->em()->getRepository(\App\Doctor\Entity\DoctorAddress::class)->findOneBy([])?->getId(),
|
||||
);
|
||||
$entry->setPriority(10 - $i);
|
||||
if ($i === 0) {
|
||||
$entry->setPreferredDayParts(['morning']);
|
||||
}
|
||||
$this->em()->persist($entry);
|
||||
$made++;
|
||||
}
|
||||
|
||||
$this->em()->flush();
|
||||
|
||||
return $made;
|
||||
}
|
||||
|
||||
/** @param PatientRecord[] $records */
|
||||
private function recordFor(array $records, User $user): ?PatientRecord
|
||||
{
|
||||
foreach ($records as $record) {
|
||||
if ($record->getUser()->getId() === $user->getId()) {
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
|
||||
return $records[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command
|
||||
foreach ($this->engineCounts as $scenario => $counts) {
|
||||
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
|
||||
}
|
||||
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'سیاست', 'رزرو واقعی', 'پکیج/دوره', 'لغو/انتظار'], $rows);
|
||||
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'رزرو واقعی'], $rows);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ final class GlobalTables
|
||||
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
|
||||
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
|
||||
\App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class,
|
||||
\App\Policy\Entity\PolicyVersionLog::class => \App\Policy\Entity\Policy::class,
|
||||
|
||||
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
|
||||
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
|
||||
@@ -118,11 +117,7 @@ final class GlobalTables
|
||||
|
||||
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
|
||||
|
||||
// سرویسهای یک پکیج جزئی از تعریف همان پکیجاند، نه دادهٔ مستقل.
|
||||
\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,
|
||||
|
||||
@@ -510,12 +510,5 @@ class HoldAndBookTest extends ApiTestCase
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame($newStart, $moved['data']['starts_at']);
|
||||
|
||||
$event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class)
|
||||
->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']);
|
||||
|
||||
self::assertNotNull($event);
|
||||
self::assertSame($start, $event->getPayload()['previous_start']);
|
||||
self::assertSame($newStart, $event->getPayload()['new_start']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
@@ -164,39 +162,4 @@ class ResourceBlockTest extends ApiTestCase
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ظرفیتی که برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود: مصرفکنندهای که
|
||||
* فقط مسدودسازی را بشنود، منبع را برای همیشه اشغال میبیند.
|
||||
*/
|
||||
public function testBlockingAndReleasingEachRecordADomainEvent(): void
|
||||
{
|
||||
[$user, $resource] = $this->clinicWithResource();
|
||||
|
||||
$start = time() + 86400;
|
||||
|
||||
$created = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [
|
||||
'starts_at' => $start,
|
||||
'ends_at' => $start + 3600,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$blocked = $this->latestEvent(DomainEvents::RESOURCE_BLOCKED);
|
||||
self::assertNotNull($blocked);
|
||||
self::assertSame($resource->getUuid(), $blocked->getPayload()['resource_uuid']);
|
||||
self::assertSame($start, $blocked->getPayload()['starts_at']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/resource-block/{$created['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$released = $this->latestEvent(DomainEvents::RESOURCE_RELEASED);
|
||||
self::assertNotNull($released);
|
||||
self::assertSame($created['data']['uuid'], $released->getPayload()['block_uuid']);
|
||||
}
|
||||
|
||||
private function latestEvent(string $name): ?DomainEventLog
|
||||
{
|
||||
return $this->em->getRepository(DomainEventLog::class)
|
||||
->findOneBy(['name' => $name], ['id' => 'DESC']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user