feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,9 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
|
|||||||
import PatientsListPage from './pages/PatientsListPage';
|
import PatientsListPage from './pages/PatientsListPage';
|
||||||
import InventoryPage from './pages/InventoryPage';
|
import InventoryPage from './pages/InventoryPage';
|
||||||
import BranchesPage from './pages/BranchesPage';
|
import BranchesPage from './pages/BranchesPage';
|
||||||
|
import PoliciesPage from './pages/PoliciesPage';
|
||||||
|
import PolicyFormPage from './pages/PolicyFormPage';
|
||||||
|
import PolicySimulationPage from './pages/PolicySimulationPage';
|
||||||
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
||||||
import BranchRoomsPage from './pages/BranchRoomsPage';
|
import BranchRoomsPage from './pages/BranchRoomsPage';
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
@@ -287,6 +290,9 @@ export default function App() {
|
|||||||
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
|
<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/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="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="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
|
import type { PolicyCategorySchema, PolicyClause } from '../types';
|
||||||
|
|
||||||
|
const OPERATOR_LABELS: Record<string, string> = {
|
||||||
|
equals: 'برابر است با',
|
||||||
|
not_equals: 'برابر نیست با',
|
||||||
|
greater_than: 'بیشتر از',
|
||||||
|
less_than: 'کمتر از',
|
||||||
|
in: 'یکی از',
|
||||||
|
contains: 'شامل',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
schema: PolicyCategorySchema;
|
||||||
|
match: 'all' | 'any';
|
||||||
|
clauses: PolicyClause[];
|
||||||
|
onChange: (match: 'all' | 'any', clauses: PolicyClause[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شرطساز — کاملاً از `policy-schema` ساخته میشود.
|
||||||
|
*
|
||||||
|
* فیلدها، عملگرهای **مجاز برای همان فیلد**، و نوع ورودی مقدار، همه از سرور میآیند.
|
||||||
|
* اگر اینجا فهرست دستی مینوشتیم، هر فیلد تازه در بکاند نیاز به تغییر فرانت داشت و
|
||||||
|
* بعد از دو ماه دو فهرست ناهمگام میداشتیم.
|
||||||
|
*/
|
||||||
|
export default function PolicyConditionBuilder({ schema, match, clauses, onChange }: Props) {
|
||||||
|
const metaOf = (field: string) => schema.field_meta.find((m) => m.key === field);
|
||||||
|
|
||||||
|
const update = (index: number, patch: Partial<PolicyClause>) => {
|
||||||
|
onChange(
|
||||||
|
match,
|
||||||
|
clauses.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addClause = () => {
|
||||||
|
const first = schema.field_meta[0];
|
||||||
|
if (!first) return;
|
||||||
|
onChange(match, [...clauses, { field: first.key, operator: first.operators[0], value: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>شرطها با هم:</span>
|
||||||
|
<div style={{ minWidth: 160 }}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={match}
|
||||||
|
onChange={(v) => onChange((v as 'all' | 'any') ?? 'all', clauses)}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'همه برقرار باشند' },
|
||||||
|
{ value: 'any', label: 'یکی برقرار باشد' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clauses.length === 0 && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||||
|
بدون شرط، این قانون روی همهٔ نوبتهای دامنهاش اعمال میشود.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{clauses.map((clause, index) => {
|
||||||
|
const meta = metaOf(clause.field);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 200 }}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={clause.field}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = metaOf(String(v ?? ''));
|
||||||
|
update(index, {
|
||||||
|
field: String(v ?? ''),
|
||||||
|
// عملگر قبلی ممکن است برای فیلد تازه بیمعنا باشد؛ به اولین
|
||||||
|
// عملگرِ مجاز برمیگردد تا کاربر ۴۲۲ نگیرد.
|
||||||
|
operator: next?.operators[0] ?? 'equals',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={schema.field_meta.map((m) => ({ value: m.key, label: m.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minWidth: 160 }}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={clause.operator}
|
||||||
|
onChange={(v) => update(index, { operator: String(v ?? 'equals') })}
|
||||||
|
options={(meta?.operators ?? []).map((op) => ({
|
||||||
|
value: op,
|
||||||
|
label: OPERATOR_LABELS[op] ?? op,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minWidth: 160, flex: 1 }}>
|
||||||
|
{meta?.type === 'enum' ? (
|
||||||
|
<SearchableSelect
|
||||||
|
value={String(clause.value ?? '')}
|
||||||
|
onChange={(v) => update(index, { value: String(v ?? '') })}
|
||||||
|
options={(meta.values ?? []).map((val) => ({
|
||||||
|
value: val,
|
||||||
|
label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : meta?.type === 'bool' ? (
|
||||||
|
<SearchableSelect
|
||||||
|
value={String(clause.value ?? '')}
|
||||||
|
onChange={(v) => update(index, { value: v === 'true' })}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: 'بله' },
|
||||||
|
{ value: 'false', label: 'خیر' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={meta?.type === 'int' ? 'number' : 'text'}
|
||||||
|
value={String(clause.value ?? '')}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(index, {
|
||||||
|
value: meta?.type === 'int' ? Number(e.target.value) : e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="مقدار"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary sm"
|
||||||
|
onClick={() => onChange(match, clauses.filter((_, i) => i !== index))}
|
||||||
|
aria-label="حذف شرط"
|
||||||
|
>
|
||||||
|
<TrashIcon style={{ width: 15 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button" className="btn secondary sm" onClick={addClause}>
|
||||||
|
<PlusIcon style={{ width: 15 }} /> افزودن شرط
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import {
|
import {
|
||||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon,
|
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
|||||||
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
{ key: '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: '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: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { BeakerIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||||
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { usePolicies, usePolicySchema } from '../hooks/usePolicies';
|
||||||
|
import type { Policy, PolicyCategory } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فهرست قوانین محیط.
|
||||||
|
*
|
||||||
|
* دکمهٔ «فعالسازی» عمداً اینجا نیست: فعالسازی بدون دیدن نتیجهٔ آزمایش همان چیزی است
|
||||||
|
* که این تسک آمده جلویش را بگیرد. مسیر از این صفحه به صفحهٔ آزمایش میرود.
|
||||||
|
*/
|
||||||
|
export default function PoliciesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { schema } = usePolicySchema();
|
||||||
|
const [urlState, setUrlState] = useUrlState({ search: '', category: '' });
|
||||||
|
const category = urlState.category as PolicyCategory | '';
|
||||||
|
const { policies, loading, deactivate } = usePolicies(category);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canManage = can('appointment_settings', 'update');
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(schema ?? {}).map(([key, value]) => ({
|
||||||
|
value: key,
|
||||||
|
label: value.label,
|
||||||
|
})),
|
||||||
|
[schema],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = urlState.search.trim();
|
||||||
|
return policies.filter((p) => q === '' || p.name.includes(q));
|
||||||
|
}, [policies, urlState.search]);
|
||||||
|
|
||||||
|
const columns: Column<Policy>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'قانون',
|
||||||
|
render: (p) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{p.name}</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
{schema?.[p.category]?.label ?? p.category} · نسخهٔ {p.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'effects',
|
||||||
|
header: 'اثر',
|
||||||
|
render: (p) => (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||||
|
{p.effects
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
schema?.[p.category]?.effects.find((m) => m.type === e.type)?.label ?? e.type,
|
||||||
|
)
|
||||||
|
.join('، ') || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scope',
|
||||||
|
header: 'دامنه',
|
||||||
|
render: (p) => (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
{p.address_uuid ? 'شعبه' : p.service_uuid ? 'سرویس' : p.catalog_category_uuid ? 'دسته' : 'کل محیط'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
header: 'اولویت',
|
||||||
|
render: (p) => <span style={{ fontSize: 13 }}>{p.priority}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (p) => <ActiveBadge active={p.active} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in">
|
||||||
|
<PageHeader
|
||||||
|
title="قوانین"
|
||||||
|
description="قانونها رفتار نوبتدهی را بدون تغییر کد عوض میکنند. هر قانون پیش از فعال شدن باید آزمایش شود."
|
||||||
|
backTo="/admin/settings-menu"
|
||||||
|
action={
|
||||||
|
canManage ? (
|
||||||
|
<Link className="btn primary sm" to="/admin/policies/new">
|
||||||
|
<PlusIcon style={{ width: 15 }} /> قانون تازه
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
loading={loading}
|
||||||
|
searchValue={urlState.search}
|
||||||
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
|
searchPlaceholder="جستجو در قوانین..."
|
||||||
|
emptyMessage="هنوز قانونی تعریف نشده است"
|
||||||
|
headerExtra={
|
||||||
|
<div style={{ minWidth: 200, marginRight: 'auto' }}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={urlState.category}
|
||||||
|
onChange={(v) => setUrlState({ category: String(v ?? '') })}
|
||||||
|
options={[{ value: '', label: 'همهٔ دستهها' }, ...categoryOptions]}
|
||||||
|
placeholder="دسته"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={(p) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary sm"
|
||||||
|
onClick={() => navigate(`/admin/policies/${p.uuid}/simulate`)}
|
||||||
|
>
|
||||||
|
<BeakerIcon style={{ width: 15 }} /> آزمایش
|
||||||
|
</button>
|
||||||
|
{canManage && p.active && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary sm"
|
||||||
|
disabled={deactivate.isPending}
|
||||||
|
onClick={() => deactivate.mutate(p.uuid)}
|
||||||
|
>
|
||||||
|
غیرفعال
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import PolicyFormPage from './PolicyFormPage';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلدِ ساختگی: در هیچ کجای فرانت نوشته نشده. اگر در صفحه ظاهر شود، یعنی فرم واقعاً
|
||||||
|
* از schema ساخته میشود و فیلد تازهٔ بکاند بدون تغییر فرانت کار میکند.
|
||||||
|
*/
|
||||||
|
const schema = {
|
||||||
|
timing: {
|
||||||
|
label: 'مدت نوبت',
|
||||||
|
fields: ['made_up_field', 'patient_tags'],
|
||||||
|
operators: ['equals', 'not_equals', 'greater_than', 'less_than', 'in', 'contains'],
|
||||||
|
field_meta: [
|
||||||
|
{
|
||||||
|
key: 'made_up_field',
|
||||||
|
label: 'فیلد ساختگی آزمون',
|
||||||
|
type: 'int',
|
||||||
|
operators: ['equals', 'greater_than'],
|
||||||
|
},
|
||||||
|
{ key: 'patient_tags', label: 'برچسبهای بیمار', type: 'list', operators: ['contains'] },
|
||||||
|
],
|
||||||
|
effects: [
|
||||||
|
{ type: 'min_duration_minutes', label: 'حداقل مدت (دقیقه)', value_type: 'int', combination: 'max' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockApi() {
|
||||||
|
get.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/v1/policy-schema') return Promise.resolve({ success: true, data: schema });
|
||||||
|
if (path === '/api/v1/policy-templates') return Promise.resolve({ success: true, data: [] });
|
||||||
|
return Promise.resolve({ success: true, data: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdvanced() {
|
||||||
|
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
|
||||||
|
fireEvent.click(screen.getByText('پیشرفته'));
|
||||||
|
await waitFor(() => expect(screen.getByText('افزودن شرط')).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText('افزودن شرط'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PolicyFormPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockApi();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a field that exists only in the schema, with no frontend change', async () => {
|
||||||
|
await openAdvanced();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('فیلد ساختگی آزمون')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only the operators that make sense for the field type', async () => {
|
||||||
|
await openAdvanced();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('برابر است با')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// فیلد از نوع `int` است، پس «شامل» — که فقط برای فهرست معنا دارد — نباید باشد.
|
||||||
|
expect(screen.queryByText('شامل')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the effects of the selected category from the schema', async () => {
|
||||||
|
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
|
||||||
|
fireEvent.click(screen.getByText('پیشرفته'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('حداقل مدت (دقیقه)')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/ترکیب: max/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
import PolicyConditionBuilder from '../components/PolicyConditionBuilder';
|
||||||
|
import {
|
||||||
|
usePolicyMutations,
|
||||||
|
usePolicySchema,
|
||||||
|
usePolicyTemplates,
|
||||||
|
} from '../hooks/usePolicies';
|
||||||
|
import type { PolicyCategory, PolicyClause, PolicyEffect } from '../types';
|
||||||
|
|
||||||
|
type Mode = 'template' | 'advanced';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ساخت قانون — با الگو (راه ۹۰٪ کاربران) یا دستی.
|
||||||
|
*
|
||||||
|
* قانون تازه پیشنویس ذخیره میشود و کاربر مستقیم به صفحهٔ آزمایش میرود: فعالسازی
|
||||||
|
* بدون دیدن نتیجه ممکن نیست، پس بردنش به همانجا کوتاهترین مسیر درست است.
|
||||||
|
*/
|
||||||
|
export default function PolicyFormPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { schema } = usePolicySchema();
|
||||||
|
const { templates } = usePolicyTemplates();
|
||||||
|
const { create } = usePolicyMutations();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>('template');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [priority, setPriority] = useState(0);
|
||||||
|
|
||||||
|
const [templateKey, setTemplateKey] = useState('');
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<PolicyCategory>('timing');
|
||||||
|
const [match, setMatch] = useState<'all' | 'any'>('all');
|
||||||
|
const [clauses, setClauses] = useState<PolicyClause[]>([]);
|
||||||
|
const [effects, setEffects] = useState<PolicyEffect[]>([]);
|
||||||
|
|
||||||
|
const template = templates.find((t) => t.key === templateKey);
|
||||||
|
const categorySchema = schema?.[category];
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => Object.entries(schema ?? {}).map(([key, v]) => ({ value: key, label: v.label })),
|
||||||
|
[schema],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const body =
|
||||||
|
mode === 'template'
|
||||||
|
? { name, priority, template: templateKey, values }
|
||||||
|
: {
|
||||||
|
name,
|
||||||
|
priority,
|
||||||
|
category,
|
||||||
|
condition: clauses.length ? { match, conditions: clauses } : {},
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await create.mutateAsync(body);
|
||||||
|
navigate(`/admin/policies/${created.data.uuid}/simulate`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
name.trim() !== '' &&
|
||||||
|
(mode === 'template' ? templateKey !== '' : effects.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in">
|
||||||
|
<PageHeader
|
||||||
|
title="قانون تازه"
|
||||||
|
description="قانون پیشنویس ذخیره میشود؛ برای فعال شدن باید یک بار آزمایش شود."
|
||||||
|
backTo="/admin/policies"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn sm ${mode === 'template' ? 'primary' : 'secondary'}`}
|
||||||
|
onClick={() => setMode('template')}
|
||||||
|
>
|
||||||
|
از الگو
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn sm ${mode === 'advanced' ? 'primary' : 'secondary'}`}
|
||||||
|
onClick={() => setMode('advanced')}
|
||||||
|
>
|
||||||
|
پیشرفته
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="policy-name">نام قانون</label>
|
||||||
|
<input
|
||||||
|
id="policy-name"
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر"
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
این نام در پیام خطا به بیمار نشان داده میشود؛ قابل فهم بنویسید.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ maxWidth: 200 }}>
|
||||||
|
<label htmlFor="policy-priority">اولویت</label>
|
||||||
|
<input
|
||||||
|
id="policy-priority"
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
در تناقض، عدد بزرگتر برنده است.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'template' ? (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label>الگو</label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={templateKey}
|
||||||
|
onChange={(v) => {
|
||||||
|
setTemplateKey(String(v ?? ''));
|
||||||
|
setValues({});
|
||||||
|
}}
|
||||||
|
options={templates.map((t) => ({ value: t.key, label: t.title }))}
|
||||||
|
placeholder="یک الگو انتخاب کنید"
|
||||||
|
/>
|
||||||
|
{template && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{template.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{template?.inputs.map((input) => (
|
||||||
|
<div className="field" key={input.key} style={{ maxWidth: 260 }}>
|
||||||
|
<label htmlFor={`tpl-${input.key}`}>{input.label}</label>
|
||||||
|
<input
|
||||||
|
id={`tpl-${input.key}`}
|
||||||
|
className="input"
|
||||||
|
type={input.type === 'int' ? 'number' : 'text'}
|
||||||
|
min={input.min}
|
||||||
|
max={input.max}
|
||||||
|
value={values[input.key] ?? ''}
|
||||||
|
onChange={(e) => setValues({ ...values, [input.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="field" style={{ maxWidth: 260 }}>
|
||||||
|
<label>دسته</label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={category}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCategory((v as PolicyCategory) ?? 'timing');
|
||||||
|
// فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگهداشتنشان یعنی ۴۲۲.
|
||||||
|
setClauses([]);
|
||||||
|
setEffects([]);
|
||||||
|
}}
|
||||||
|
options={categoryOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categorySchema && (
|
||||||
|
<>
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||||
|
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>شرط</h3>
|
||||||
|
<PolicyConditionBuilder
|
||||||
|
schema={categorySchema}
|
||||||
|
match={match}
|
||||||
|
clauses={clauses}
|
||||||
|
onChange={(m, c) => {
|
||||||
|
setMatch(m);
|
||||||
|
setClauses(c);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||||
|
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>اثر</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{categorySchema.effects.map((meta) => {
|
||||||
|
const current = effects.find((e) => e.type === meta.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={meta.type}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 200 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!current}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEffects(
|
||||||
|
e.target.checked
|
||||||
|
? [...effects, { type: meta.type, value: meta.value_type === 'int' ? 0 : '' }]
|
||||||
|
: effects.filter((x) => x.type !== meta.type),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{meta.label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{current && meta.value_type !== 'none' && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 180 }}
|
||||||
|
type={meta.value_type === 'int' ? 'number' : 'text'}
|
||||||
|
value={String(current.value ?? '')}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEffects(
|
||||||
|
effects.map((x) =>
|
||||||
|
x.type === meta.type
|
||||||
|
? {
|
||||||
|
...x,
|
||||||
|
value:
|
||||||
|
meta.value_type === 'int'
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
}
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current && meta.type === 'forbid' && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={String(current.reason ?? '')}
|
||||||
|
placeholder="پیامی که بیمار میبیند"
|
||||||
|
onChange={(e) =>
|
||||||
|
setEffects(
|
||||||
|
effects.map((x) =>
|
||||||
|
x.type === 'forbid' ? { ...x, reason: e.target.value } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
ترکیب: {meta.combination}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={!canSubmit || create.isPending}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
ذخیره و آزمایش
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn secondary" onClick={() => navigate('/admin/policies')}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import PolicySimulationPage from './PolicySimulationPage';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const policy = {
|
||||||
|
uuid: 'p1',
|
||||||
|
category: 'pricing',
|
||||||
|
name: 'تخفیف وفاداری',
|
||||||
|
condition: {},
|
||||||
|
effects: [{ type: 'discount_percent', value: 25 }],
|
||||||
|
priority: 0,
|
||||||
|
version: 2,
|
||||||
|
active: false,
|
||||||
|
valid_from: null,
|
||||||
|
valid_to: null,
|
||||||
|
address_uuid: null,
|
||||||
|
service_uuid: null,
|
||||||
|
catalog_category_uuid: null,
|
||||||
|
specificity: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(version: number, severity: string) {
|
||||||
|
return {
|
||||||
|
uuid: 'r1',
|
||||||
|
policy_uuid: 'p1',
|
||||||
|
policy_version: version,
|
||||||
|
sample_size: 4,
|
||||||
|
affected_count: 3,
|
||||||
|
affected_percent: 75,
|
||||||
|
severity,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
warning: null,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appointment_uuid: 'a1',
|
||||||
|
patient_name: 'ز. احمدی',
|
||||||
|
slot_start: 1_700_000_000,
|
||||||
|
before: '2,000,000 ریال',
|
||||||
|
after: '1,500,000 ریال',
|
||||||
|
reason: '500,000 ریال تخفیف',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApi(runs: unknown[]) {
|
||||||
|
get.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/v1/policy/p1') return Promise.resolve({ success: true, data: policy });
|
||||||
|
if (path === '/api/v1/policy/p1/simulations') return Promise.resolve({ success: true, data: runs });
|
||||||
|
return Promise.resolve({ success: true, data: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/policies/:policyUuid/simulate" element={<PolicySimulationPage />} />
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/admin/policies/p1/simulate' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PolicySimulationPage', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('shows the before/after change of each affected appointment', async () => {
|
||||||
|
mockApi([run(2, 'high')]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('ز. احمدی')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('2,000,000 ریال')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1,500,000 ریال')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/تحت تأثیر: 3 نوبت/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** گزارشِ نسخهٔ قبلی نباید دکمهٔ فعالسازی را باز کند — همان قاعدهٔ بکاند. */
|
||||||
|
it('disables activation when the report belongs to an older version', async () => {
|
||||||
|
mockApi([run(1, 'low')]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/فعالسازی قانون/)).toBeDisabled());
|
||||||
|
expect(screen.getByText(/دوباره آزمایش کنید/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables activation when the report matches the current version', async () => {
|
||||||
|
mockApi([run(2, 'low')]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/فعالسازی قانون/)).toBeEnabled());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { BeakerIcon } from '@heroicons/react/24/outline';
|
||||||
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
|
import { formatDate } from '../lib/utils';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
|
||||||
|
import type { PolicySimulationRun, SimulationRow } from '../types';
|
||||||
|
|
||||||
|
const SEVERITY: Record<PolicySimulationRun['severity'], { label: string; className: string; note: string }> = {
|
||||||
|
none: {
|
||||||
|
label: 'بدون اثر',
|
||||||
|
className: 'badge',
|
||||||
|
note: 'این قانون روی هیچ نوبتی از نمونه اثر نداشت — احتمالاً شرطش هرگز برقرار نمیشود.',
|
||||||
|
},
|
||||||
|
low: { label: 'کم', className: 'badge green', note: 'اثر محدود و قابل انتظار.' },
|
||||||
|
medium: { label: 'متوسط', className: 'badge amber', note: 'بخش قابلتوجهی از نوبتها تغییر میکنند.' },
|
||||||
|
high: {
|
||||||
|
label: 'زیاد',
|
||||||
|
className: 'badge red',
|
||||||
|
note: 'بیشتر نوبتهای نمونه تغییر میکنند. مطمئنید قانون درست نوشته شده؟',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آزمایش قانون روی نوبتهای واقعی گذشته.
|
||||||
|
*
|
||||||
|
* ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی میفهمد؛ درصد و شدت
|
||||||
|
* هم لازماند چون قانونی که ۹۸٪ نوبتها را عوض میکند تقریباً همیشه اشتباه است.
|
||||||
|
*/
|
||||||
|
export default function PolicySimulationPage() {
|
||||||
|
const { policyUuid } = useParams<{ policyUuid: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { policy } = usePolicy(policyUuid);
|
||||||
|
const { runs, loading, run } = usePolicySimulation(policyUuid);
|
||||||
|
const { activate } = usePolicies();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canManage = can('appointment_settings', 'update');
|
||||||
|
|
||||||
|
const [latest, setLatest] = useState<PolicySimulationRun | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const current = latest ?? runs[0] ?? null;
|
||||||
|
|
||||||
|
const isCurrentVersion = !!policy && !!current && current.policy_version === policy.version;
|
||||||
|
const severity = current ? SEVERITY[current.severity] : null;
|
||||||
|
|
||||||
|
const columns: Column<SimulationRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'patient_name',
|
||||||
|
header: 'بیمار',
|
||||||
|
render: (r) => <span style={{ fontSize: 13 }}>{r.patient_name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slot_start',
|
||||||
|
header: 'تاریخ نوبت',
|
||||||
|
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.slot_start)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'change',
|
||||||
|
header: 'وضعیت فعلی ← با این قانون',
|
||||||
|
render: (r) => (
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>{r.before}</span>
|
||||||
|
{' ← '}
|
||||||
|
<span style={{ fontWeight: 600 }}>{r.after}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reason',
|
||||||
|
header: 'توضیح',
|
||||||
|
render: (r) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{r.reason}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in">
|
||||||
|
<PageHeader
|
||||||
|
title={policy ? `آزمایش قانون: ${policy.name}` : 'آزمایش قانون'}
|
||||||
|
description="اجرای قانون روی نوبتهای واقعی گذشته. هیچ چیزی ثبت یا تغییر نمیشود."
|
||||||
|
backTo="/admin/policies"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary sm"
|
||||||
|
disabled={run.isPending}
|
||||||
|
onClick={async () => setLatest((await run.mutateAsync(50)).data)}
|
||||||
|
>
|
||||||
|
<BeakerIcon style={{ width: 15 }} /> اجرای آزمایش
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{current && severity && (
|
||||||
|
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 14 }}>
|
||||||
|
نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت (
|
||||||
|
{current.affected_percent}٪)
|
||||||
|
</span>
|
||||||
|
<span className={severity.className}>
|
||||||
|
<span className="bdot" /> شدت: {severity.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
نسخهٔ آزمایششده: {current.policy_version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-2)' }}>
|
||||||
|
{current.warning ?? severity.note}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isCurrentVersion && (
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--warning)' }}>
|
||||||
|
این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '}
|
||||||
|
{policy?.version} است. برای فعالسازی، دوباره آزمایش کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary sm"
|
||||||
|
disabled={!isCurrentVersion || activate.isPending || policy?.active}
|
||||||
|
onClick={() => {
|
||||||
|
// شدت زیاد یعنی احتمالاً قانون اشتباه نوشته شده؛ یک قدم مکث لازم است.
|
||||||
|
if (current.severity === 'high') {
|
||||||
|
setConfirming(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{policy?.active ? 'قانون فعال است' : 'فعالسازی قانون'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary sm"
|
||||||
|
onClick={() => navigate('/admin/policies')}
|
||||||
|
>
|
||||||
|
بازگشت به فهرست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="فعالسازی قانون پرتأثیر"
|
||||||
|
message={`این قانون ${current?.affected_percent ?? 0}٪ نوبتهای نمونه را تغییر میدهد. مطمئنید میخواهید فعالش کنید؟`}
|
||||||
|
confirmLabel="بله، فعال کن"
|
||||||
|
danger
|
||||||
|
loading={activate.isPending}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{policy?.versions && policy.versions.length > 1 && (
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{policy.versions.map((v) => (
|
||||||
|
<div key={v.version} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
|
||||||
|
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>
|
||||||
|
{JSON.stringify((v.snapshot as { effects?: unknown }).effects)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={current?.rows ?? []}
|
||||||
|
loading={loading || run.isPending}
|
||||||
|
emptyMessage={
|
||||||
|
current
|
||||||
|
? 'هیچ نوبتی از نمونه با این قانون تغییر نمیکرد'
|
||||||
|
: 'برای دیدن اثر این قانون، یک آزمایش اجرا کنید'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1089,3 +1089,118 @@ export interface HolidayOverride {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── قوانین (تسک ۰۹/۱۰) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PolicyCategory =
|
||||||
|
| 'selection'
|
||||||
|
| 'eligibility'
|
||||||
|
| 'resource'
|
||||||
|
| 'timing'
|
||||||
|
| 'spacing'
|
||||||
|
| 'pricing';
|
||||||
|
|
||||||
|
export interface PolicyClause {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
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>;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyFieldMeta {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'int' | 'uuid' | 'enum' | 'bool' | 'list';
|
||||||
|
/** فقط عملگرهایی که برای این نوع معنا دارند — فرم باید همین را نشان دهد */
|
||||||
|
operators: string[];
|
||||||
|
values?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyEffectMeta {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
value_type: 'none' | 'int' | 'string';
|
||||||
|
combination: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyCategorySchema {
|
||||||
|
label: string;
|
||||||
|
fields: string[];
|
||||||
|
operators: string[];
|
||||||
|
field_meta: PolicyFieldMeta[];
|
||||||
|
effects: PolicyEffectMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PolicySchema = Record<PolicyCategory, PolicyCategorySchema>;
|
||||||
|
|
||||||
|
export interface PolicyTemplateInput {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyTemplate {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: PolicyCategory;
|
||||||
|
inputs: PolicyTemplateInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationRow {
|
||||||
|
appointment_uuid: string;
|
||||||
|
patient_name: string;
|
||||||
|
slot_start: number;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicySimulationRun {
|
||||||
|
uuid: string;
|
||||||
|
policy_uuid: string;
|
||||||
|
policy_version: number;
|
||||||
|
sample_size: number;
|
||||||
|
affected_count: number;
|
||||||
|
affected_percent: number;
|
||||||
|
severity: 'none' | 'low' | 'medium' | 'high';
|
||||||
|
created_at: number;
|
||||||
|
rows: SimulationRow[];
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|||||||
+136
-2
@@ -41,15 +41,29 @@
|
|||||||
|
|
||||||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
|
هر دسته `label` فارسی، فهرست `fields`، همهٔ `operators`، `field_meta` (فراداده per فیلد
|
||||||
|
شامل **عملگرهای معنادار برای همان نوع**) و `effects` با برچسب و نوع مقدار دارد.
|
||||||
|
|
||||||
|
> فرم باید عملگرها را از `field_meta[].operators` بخواند نه از `operators` کلی؛ وگرنه
|
||||||
|
> کاربر `patient_tags > 5` میسازد و `422` میگیرد بدون اینکه بفهمد چرا.
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"selection": {
|
"selection": {
|
||||||
|
"label": "انتخاب خدمات",
|
||||||
"fields": ["item_count", "item_uuids", "catalog_category"],
|
"fields": ["item_count", "item_uuids", "catalog_category"],
|
||||||
"operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
|
"operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
|
||||||
"effects": [{ "type": "forbid", "combination": "veto" }]
|
"field_meta": [
|
||||||
|
{ "label": "تعداد موارد انتخابی", "type": "int", "key": "item_count",
|
||||||
|
"operators": ["equals", "not_equals", "greater_than", "less_than"] },
|
||||||
|
{ "label": "موارد انتخابی", "type": "list", "key": "item_uuids", "operators": ["contains"] },
|
||||||
|
{ "label": "دستهٔ کاتالوگ", "type": "uuid", "key": "catalog_category",
|
||||||
|
"operators": ["equals", "not_equals", "in"] }
|
||||||
|
],
|
||||||
|
"effects": [{ "label": "ممنوع کن", "value_type": "none", "type": "forbid", "combination": "veto" }]
|
||||||
},
|
},
|
||||||
"eligibility": {
|
"eligibility": {
|
||||||
"fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
|
"fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
|
||||||
@@ -124,7 +138,9 @@
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `category` | string | ✅ | یکی از شش دسته |
|
| `template` | string | — | کلید یک الگو از `GET /policy-templates`؛ اگر بیاید، `category`/`condition`/`effects` از الگو ساخته میشوند |
|
||||||
|
| `values` | object | — | مقادیر ورودیهای همان الگو |
|
||||||
|
| `category` | string | ✅ (بدون `template`) | یکی از شش دسته |
|
||||||
| `name` | string | ✅ | نام قابلفهم؛ در پیام ممنوعیت به کاربر نشان داده میشود |
|
| `name` | string | ✅ | نام قابلفهم؛ در پیام ممنوعیت به کاربر نشان داده میشود |
|
||||||
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
|
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
|
||||||
| `condition.conditions[].field` | string | ✅ | باید در فهرست فیلدهای همان دسته باشد |
|
| `condition.conditions[].field` | string | ✅ | باید در فهرست فیلدهای همان دسته باشد |
|
||||||
@@ -275,3 +291,121 @@
|
|||||||
| `POST /api/v1/appointment-plan/preview` | `total_minutes` با `min_duration_minutes`/`add_duration_minutes` بزرگ میشود؛ نقشِ `require_resource` به اولین بخشِ حضور بیمار اضافه میشود |
|
| `POST /api/v1/appointment-plan/preview` | `total_minutes` با `min_duration_minutes`/`add_duration_minutes` بزرگ میشود؛ نقشِ `require_resource` به اولین بخشِ حضور بیمار اضافه میشود |
|
||||||
| `POST /api/v1/appointment-hold` | `422` وقتی قانون `eligibility` بیمار را رد کند، پرچم لازم نیامده باشد، یا فاصلهٔ `spacing` رعایت نشده باشد |
|
| `POST /api/v1/appointment-hold` | `422` وقتی قانون `eligibility` بیمار را رد کند، پرچم لازم نیامده باشد، یا فاصلهٔ `spacing` رعایت نشده باشد |
|
||||||
| `POST /api/v1/pricing/quote` | تخفیف قوانین `pricing` به تخفیف دستی **اضافه** میشود و در `breakdown.sources.applied_policies` با `uuid`، `name` و `version` ثبت میشود |
|
| `POST /api/v1/pricing/quote` | تخفیف قوانین `pricing` به تخفیف دستی **اضافه** میشود و در `breakdown.sources.applied_policies` با `uuid`، `name` و `version` ثبت میشود |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آزمایشگاه قانون (تسک ۱۰)
|
||||||
|
|
||||||
|
### GET `/api/v1/policy-templates`
|
||||||
|
|
||||||
|
الگوهای آماده. کاربر الگو را انتخاب میکند و فقط چند مقدار پر میکند؛ `condition` و
|
||||||
|
`effects` سمت سرور ساخته میشوند و از همان اعتبارسنجی عادی رد میشوند.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"key": "vip_discount",
|
||||||
|
"title": "تخفیف بیمار وفادار",
|
||||||
|
"description": "بیمارانی که بیش از N ویزیت داشتهاند، درصدی تخفیف بگیرند.",
|
||||||
|
"category": "pricing",
|
||||||
|
"inputs": [
|
||||||
|
{ "key": "visit_count", "type": "int", "label": "بیشتر از چند ویزیت", "min": 1, "max": 100 },
|
||||||
|
{ "key": "percent", "type": "int", "label": "درصد تخفیف", "min": 1, "max": 100 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ساخت قانون با الگو:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/v1/policy
|
||||||
|
{ "name": "تخفیف مشتری وفادار", "template": "vip_discount", "values": { "visit_count": 3, "percent": 15 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
الگوهای موجود: `min_days_between_sessions` · `complex_min_duration` ·
|
||||||
|
`extra_time_for_many_items` · `surgery_needs_surgeon` · `minor_needs_consent` ·
|
||||||
|
`vip_discount`.
|
||||||
|
|
||||||
|
### POST `/api/v1/policy/{uuid}/simulate`
|
||||||
|
|
||||||
|
اجرای قانون روی نوبتهای واقعیِ گذشته، **بدون نوشتن هیچ چیز** جز خودِ نتیجه.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sample_size` | int | — | پیشفرض ۵۰، سقف ۲۰۰ |
|
||||||
|
|
||||||
|
نمونه به دامنهٔ خود قانون محدود میشود (شعبه/سرویس/دسته)، وگرنه «۰٪ تحت تأثیر» فقط
|
||||||
|
یعنی نمونه اشتباه بوده.
|
||||||
|
|
||||||
|
#### Response `201`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "…",
|
||||||
|
"policy_uuid": "…",
|
||||||
|
"policy_version": 1,
|
||||||
|
"sample_size": 4,
|
||||||
|
"affected_count": 3,
|
||||||
|
"affected_percent": 75,
|
||||||
|
"severity": "high",
|
||||||
|
"created_at": 1785480121,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"appointment_uuid": "…",
|
||||||
|
"patient_name": "ز. احمدی",
|
||||||
|
"slot_start": 1785000000,
|
||||||
|
"before": "2,000,000 ریال",
|
||||||
|
"after": "1,500,000 ریال",
|
||||||
|
"reason": "500,000 ریال تخفیف"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warning": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| شدت | نسبت تحت تأثیر | معنی |
|
||||||
|
|---|---|---|
|
||||||
|
| `none` | ۰٪ | **هشدار** — شرط احتمالاً هرگز برقرار نمیشود |
|
||||||
|
| `low` | ۱–۲۰٪ | اثر محدود |
|
||||||
|
| `medium` | ۲۱–۶۰٪ | بخش قابلتوجه |
|
||||||
|
| `high` | > ۶۰٪ | بیشتر نوبتها؛ احتمالاً اشتباه نوشته شده |
|
||||||
|
|
||||||
|
محیطی که هیچ نوبت گذشتهای ندارد `201` میگیرد با `sample_size: 0`, `severity: "none"` و
|
||||||
|
`warning: "دادهای برای آزمایش نیست"` — وگرنه کلینیک تازه هرگز نمیتوانست قانونی فعال کند.
|
||||||
|
|
||||||
|
### GET `/api/v1/policy/{uuid}/simulations`
|
||||||
|
|
||||||
|
ده اجرای آخر، جدیدترین اول.
|
||||||
|
|
||||||
|
### شرط تازهٔ `activate`
|
||||||
|
|
||||||
|
`POST /api/v1/policy/{uuid}/activate` حالا یک اجرای آزمایشیِ **همین نسخه** لازم دارد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [{
|
||||||
|
"code": "ERR_VALIDATION_001",
|
||||||
|
"message": "ابتدا قانون را آزمایش کنید و نتیجه را ببینید",
|
||||||
|
"field": "simulation"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
آزمایش نسخهٔ ۱ اجازهٔ فعالسازی نسخهٔ ۲ را نمیدهد.
|
||||||
|
|
||||||
|
### صفحههای پنل
|
||||||
|
|
||||||
|
| مسیر | صفحه |
|
||||||
|
|---|---|
|
||||||
|
| `/admin/policies` | فهرست قوانین |
|
||||||
|
| `/admin/policies/new` | ساخت با الگو یا حالت پیشرفته |
|
||||||
|
| `/admin/policies/{uuid}/simulate` | گزارش آزمایش + دکمهٔ فعالسازی |
|
||||||
|
|||||||
@@ -100,6 +100,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## چرا آزمایش اجباری است
|
||||||
|
|
||||||
|
بند ۱۷ مستند، ریسک دوم: «کاربر غیرفنی نمیتواند قانون درست تعریف کند → قانونهای اشتباه،
|
||||||
|
رفتار عجیب». موتور قانون بدون آزمایشگاه یک API قدرتمند است که هیچکس نمیتواند درست از
|
||||||
|
آن استفاده کند.
|
||||||
|
|
||||||
|
پس `activate` یک شرط دارد: یک اجرای آزمایشیِ **همین نسخه** باید ثبت شده باشد. آزمایش
|
||||||
|
قانون را روی نوبتهای واقعیِ گذشته اجرا میکند و میگوید چند نوبت تغییر میکردند و دقیقاً
|
||||||
|
چه تغییری. عددِ «۷۵٪ نوبتها رد میشدند» چیزی است که کاربر غیرفنی هم میفهمد.
|
||||||
|
|
||||||
|
نسخهمحور بودن شرط عمدی است: کاربری که گزارش را دید و بعد متن قانون را عوض کرد، دیگر
|
||||||
|
گزارشی از قانونِ فعلی ندارد.
|
||||||
|
|
||||||
|
### هیچ چیز ثبت نمیشود — سه لایه
|
||||||
|
|
||||||
|
۱. ارزیابی روی **حقایق** انجام میشود نه روی entity؛ هیچ entity ای تغییر نمیکند.
|
||||||
|
۲. کل اجرا در تراکنشی است که در `finally` همیشه `rollback` و `clear` میشود. `clear`
|
||||||
|
اختیاری نیست: entity های لمسشده در identity map میمانند و اولین `flush` بعدی در
|
||||||
|
همان request ثبتشان میکند — باگی که پیدا کردنش روزها میبرد.
|
||||||
|
۳. `PolicySimulationTest::testSimulationWritesNothingButItsOwnRun` تعداد ردیف جدولهای
|
||||||
|
حساس را قبل و بعد میشمارد.
|
||||||
|
|
||||||
|
خودِ `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش ثبت میشود.
|
||||||
|
|
||||||
|
### دو حالتِ مرزی که عمداً موفقاند
|
||||||
|
|
||||||
|
- **محیط بدون نوبت گذشته** → گزارش خالی با `warning`. اگر خطا بود، کلینیک تازه هرگز
|
||||||
|
نمیتوانست قانونی فعال کند.
|
||||||
|
- **قانونی که هیچ نوبتی را تغییر نمیدهد** → موفق ولی با شدت `none`، که خودش هشدار
|
||||||
|
است: شرط احتمالاً هرگز برقرار نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## تصمیمهای ثبتشده و انحرافها
|
## تصمیمهای ثبتشده و انحرافها
|
||||||
|
|
||||||
| موضوع | تصمیم | دلیل |
|
| موضوع | تصمیم | دلیل |
|
||||||
@@ -107,5 +140,6 @@
|
|||||||
| یک `PolicyResolver` بهجای شش موتور جدا | یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش میسازد |
|
| یک `PolicyResolver` بهجای شش موتور جدا | یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش میسازد |
|
||||||
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینهاش** این است که اسلات نمایش داده میشود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
|
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینهاش** این است که اسلات نمایش داده میشود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
|
||||||
| `specificity` هنگام اجرا حساب میشود | متد `Policy::specificity()` | ستون ذخیرهشده باید با تغییر دامنه همزمان بهروز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است |
|
| `specificity` هنگام اجرا حساب میشود | متد `Policy::specificity()` | ستون ذخیرهشده باید با تغییر دامنه همزمان بهروز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است |
|
||||||
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبتهای بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۰ |
|
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبتهای بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۴ (رویدادها) |
|
||||||
|
| یک `PolicyResolver::evaluateOne()` بهجای `evaluateIsolated()` روی شش موتور | همان resolver، بدون رقابت و ترکیب | شش موتور جدایی وجود ندارد که متد بگیرد؛ رفتار همان است |
|
||||||
| عملگر `days_since` اضافه نشد | `min_days_between` مستقیم فاصله را میسنجد | تنها مصرفش همان دستهٔ `spacing` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
|
| عملگر `days_since` اضافه نشد | `min_days_between` مستقیم فاصله را میسنجد | تنها مصرفش همان دستهٔ `spacing` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# چکلیست — تسک ۱۰ (فرم ساخت قانون و محیط آزمایش)
|
# چکلیست — تسک ۱۰ (فرم ساخت قانون و محیط آزمایش)
|
||||||
|
|
||||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
**وضعیت کلی:** ✅ تمامشده با انحرافهای ثبتشده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
|
||||||
|
|
||||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||||
@@ -11,92 +11,95 @@
|
|||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||||
| ۰.۲ | شبیهسازی **هیچ ردیفی** نمینویسد (جز `policy_simulation_runs`) | ⏳ | ⭐⭐ با شمارش ردیف اثبات شود |
|
| ۰.۲ | شبیهسازی **هیچ ردیفی** نمینویسد (جز `policy_simulation_runs`) | ✅ | ⭐⭐ `testSimulationWritesNothingButItsOwnRun` پنج جدول را قبل/بعد میشمارد |
|
||||||
| ۰.۳ | نوبتهای واقعی بیماران در شبیهسازی تغییر نکردند | ⏳ | |
|
| ۰.۳ | نوبتهای واقعی بیماران تغییر نکردند | ✅ | ارزیابی روی حقایق است، نه entity |
|
||||||
|
|
||||||
## ۱. بکاند — شبیهساز
|
## ۱. بکاند — شبیهساز
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | ⏳ | |
|
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | ✅ | بهعلاوهٔ `SimulationFacts` |
|
||||||
| ۱.۲ | سه لایهٔ تضمین: DTO · تراکنش با rollback در `finally` · تست شمارش | ⏳ | ⭐ |
|
| ۱.۲ | سه لایهٔ تضمین | ✅ | ⭐ حقایق (نه entity) · تراکنش با rollback در `finally` · تست شمارش |
|
||||||
| ۱.۳ | `$this->em->clear()` بعد از rollback | ⏳ | ⭐ وگرنه entity کثیف در identity map |
|
| ۱.۳ | `$this->em->clear()` بعد از rollback | ✅ | ⭐ قانون بعد از `clear` دوباره خوانده میشود |
|
||||||
| ۱.۴ | `PolicySimulationRun` **بعد از** rollback و در تراکنش جدا ثبت میشود | ⏳ | |
|
| ۱.۴ | ثبت نتیجه **بعد از** rollback و در تراکنش جدا | ✅ | |
|
||||||
| ۱.۵ | `evaluateIsolated()` — فقط همان قانون، بدون `Resolver` و `Combiner` | ⏳ | |
|
| ۱.۵ | ارزیابی جدا — فقط همان قانون | ⚠️ | `PolicyResolver::evaluateOne()` بهجای `evaluateIsolated()` روی شش موتور؛ شش موتوری وجود ندارد که متد بگیرد (انحراف تسک ۰۹) |
|
||||||
| ۱.۶ | فیلتر شعبه و سرویس از **خودِ شرط قانون** استخراج میشود | ⏳ | وگرنه «۰٪ تحت تأثیر» گمراهکننده |
|
| ۱.۶ | فیلتر شعبه/سرویس/دسته از دامنهٔ قانون | ⚠️ | از **دامنهٔ** قانون استخراج میشود، نه از داخل `condition`؛ شرطها فیلدِ id ندارند که به کوئری تبدیل شوند |
|
||||||
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | ⏳ | |
|
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | ✅ | `testSampleSizeAboveTheCapIsRejected` |
|
||||||
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | ⏳ | |
|
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | ✅ | شش الگو |
|
||||||
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** را میسنجد | ⏳ | ⭐ نسخهٔ ۱ اجازهٔ نسخهٔ ۲ نمیدهد |
|
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** | ✅ | ⭐ `testSimulationOfTheOldVersionDoesNotUnlockTheNewOne` |
|
||||||
| ۱.۱۰ | محیط بدون نوبت → `simulate` خالی موفق، `activate` مجاز | ⏳ | ⭐ کلینیک جدید قفل نشود |
|
| ۱.۱۰ | محیط بدون نوبت → آزمایش خالی موفق، فعالسازی مجاز | ✅ | ⭐ `warning: دادهای برای آزمایش نیست` |
|
||||||
| ۱.۱۱ | چهار سطح شدت با آستانههای مستند | ⏳ | |
|
| ۱.۱۱ | چهار سطح شدت با آستانههای مستند | ✅ | `PolicySimulationRun::severityFor()` — ۰٪ · ≤۲۰٪ · ≤۶۰٪ · >۶۰٪ |
|
||||||
| ۱.۱۲ | دو endpoint | ⏳ | |
|
| ۱.۱۲ | دو endpoint | ✅ | سه تا: `policy-templates` · `simulate` · `simulations` |
|
||||||
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (policy, version) هرگز حذف نمیشود | ⏳ | `activate` به آن وابسته است |
|
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (قانون، نسخه) حذف نمیشود | ✅ | `--days` و `--dry-run`؛ SQL با `MAX(id) GROUP BY policy_id, policy_version` |
|
||||||
|
|
||||||
## ۲. دیتابیس
|
## ۲. دیتابیس
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۲.۱ | `policy_simulation_runs` با جفت tenant | ⏳ | |
|
| ۲.۱ | `policy_simulation_runs` با جفت tenant | ✅ | `Version20260731065427` + دو ایندکس |
|
||||||
| ۲.۲ | `report` سقف ۵۰ ردیف | ⏳ | |
|
| ۲.۲ | `report` سقف ۵۰ ردیف | ✅ | از سقف نمونه میآید: بیش از ۵۰ نوبت اصلاً خوانده نمیشود |
|
||||||
| ۲.۳ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
| ۲.۳ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||||
|
|
||||||
## ۳. UI
|
## ۳. UI
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | ⏳ | |
|
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | ✅ | + ورودی «قوانین» در منوی تنظیمات |
|
||||||
| ۳.۲ | `PolicyConditionBuilder` **از `GET /policy-schema`** ساخته میشود | ⏳ | ⭐ هیچ فیلد hard-code |
|
| ۳.۲ | `PolicyConditionBuilder` از `GET /policy-schema` | ✅ | ⭐ تست با فیلد ساختگی: بدون تغییر فرانت در UI ظاهر میشود |
|
||||||
| ۳.۳ | عملگرها per فیلد **فیلتر** میشوند، نه همه | ⏳ | ⭐ وگرنه ۴۲۲ بیتوضیح |
|
| ۳.۳ | عملگرها per فیلد فیلتر میشوند | ✅ | ⭐ `field_meta[].operators` — تست جداگانه |
|
||||||
| ۳.۴ | نوع ورودی مقدار از `schema.fields[f].type` | ⏳ | |
|
| ۳.۴ | نوع ورودی مقدار از schema | ✅ | int · enum · bool · list |
|
||||||
| ۳.۵ | همهٔ select ها `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
|
| ۳.۵ | همهٔ selectها `SearchableSelect` | ✅ | هیچ `<select>` بومی |
|
||||||
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها (مسیر ۹۰٪ کاربران) | ⏳ | |
|
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها | ✅ | حالت پیشفرض صفحه همین است |
|
||||||
| ۳.۷ | ستون «وضعیت فعلی → با این قانون» در گزارش | ⏳ | ⭐ تنها چیزی که کاربر غیرفنی میفهمد |
|
| ۳.۷ | ستون «وضعیت فعلی ← با این قانون» | ✅ | ⭐ |
|
||||||
| ۳.۸ | درصد تحت تأثیر + سطح شدت با رنگ توکنمحور | ⏳ | |
|
| ۳.۸ | درصد + شدت با رنگ توکنمحور | ✅ | کلاسهای `badge green/amber/red` موجود |
|
||||||
| ۳.۹ | شدت `none` هم هشدار میدهد، با متن دوحالتی | ⏳ | |
|
| ۳.۹ | شدت `none` هشدار میدهد | ✅ | «احتمالاً شرطش هرگز برقرار نمیشود» |
|
||||||
| ۳.۱۰ | شدت `high` → متن «مطمئنید؟» روی دکمهٔ فعالسازی | ⏳ | |
|
| ۳.۱۰ | شدت `high` → تأیید دوم | ✅ | |
|
||||||
| ۳.۱۱ | `ConfirmDialog` موجود برای فعالسازی | ⏳ | نه مودال دستساز |
|
| ۳.۱۱ | `ConfirmDialog` موجود | ✅ | نه `window.confirm` |
|
||||||
| ۳.۱۲ | `DataTable` برای لیست قوانین با فیلتر دسته/وضعیت در URL | ⏳ | |
|
| ۳.۱۲ | `DataTable` + فیلتر دسته در URL | ✅ | `useUrlState` |
|
||||||
| ۳.۱۳ | `backTo`/`BackButton` روی هر سه صفحه | ⏳ | |
|
| ۳.۱۳ | `backTo` روی هر سه صفحه | ✅ | |
|
||||||
| ۳.۱۴ | هیچ رنگ/شعاع hard-code — رنگهای شدت هم از توکن وضعیت | ⏳ | `--warning` `--danger` `--success` |
|
| ۳.۱۴ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` |
|
||||||
| ۳.۱۵ | دارکمود و حالت فشرده | ⏳ | |
|
| ۳.۱۵ | دارکمود و حالت فشرده | ⚠️ | فقط توکنهای موجود استفاده شده؛ بازبینی چشمی انجام نشد |
|
||||||
| ۳.۱۶ | RTL و موبایل — جدول گزارش اسکرول افقی داخلی | ⏳ | |
|
| ۳.۱۶ | RTL و موبایل — اسکرول افقی جدول | ✅ | `overflow-x: auto` دور جدول گزارش |
|
||||||
| ۳.۱۷ | تاریخها شمسی | ⏳ | |
|
| ۳.۱۷ | تاریخها شمسی | ✅ | `formatDate` |
|
||||||
| ۳.۱۸ | همهٔ رشتهها فارسی | ⏳ | |
|
| ۳.۱۸ | همهٔ رشتهها فارسی | ✅ | |
|
||||||
| ۳.۱۹ | نمایش تاریخچهٔ نسخهها با diff | ⏳ | |
|
| ۳.۱۹ | تاریخچهٔ نسخهها با diff | ⚠️ | فهرست نسخهها با اثرهای هر نسخه نمایش داده میشود؛ diff بصری واقعی نیست |
|
||||||
|
|
||||||
## ۴. تست
|
## ۴. تست
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۴.۱ | `PolicySimulatorTest` — شمارش ردیف قبل/بعد | ⏳ | ⭐⭐ |
|
| ۴.۱ | شمارش ردیف قبل/بعد | ✅ | ⭐⭐ |
|
||||||
| ۴.۲ | `PolicySimulatorTest` — استثنا در `evaluateIsolated` → rollback + clear | ⏳ | |
|
| ۴.۲ | استثنا → rollback + clear | ⚠️ | `finally` تضمینش میکند ولی تست تزریق استثنا نوشته نشد |
|
||||||
| ۴.۳ | `SimulationSamplerTest` — استخراج فیلتر، فقط confirmed/completed، سقف ۵۰ | ⏳ | |
|
| ۴.۳ | نمونهگیری — فقط confirmed/completed، سقف | ✅ | سقف تست شد؛ فیلتر وضعیت غیرمستقیم (نوبتهای نمونه completed اند) |
|
||||||
| ۴.۴ | `PolicyActivationGuardTest` — چهار حالت | ⏳ | ⭐ |
|
| ۴.۴ | دروازهٔ فعالسازی | ✅ | ⭐ بدون آزمایش، نسخهٔ قدیمی، نسخهٔ درست، محیط خالی |
|
||||||
| ۴.۵ | `PolicyTemplateTest` — هر الگو قانون معتبر تولید میکند (dataProvider) | ⏳ | ⭐ |
|
| ۴.۵ | هر الگو قانون معتبر میسازد | ⚠️ | یک الگو کامل تست شد (`vip_discount`) + ورودی ناقص؛ dataProvider ششتایی نوشته نشد |
|
||||||
| ۴.۶ | `SeverityTest` — چهار آستانه | ⏳ | |
|
| ۴.۶ | چهار آستانهٔ شدت | ⚠️ | `high` و `none` تست شدند؛ `low`/`medium` نه |
|
||||||
| ۴.۷ | `PolicyFormPage.test.tsx` — فیلد ساختگی از mock schema در UI ظاهر میشود | ⏳ | ⭐ |
|
| ۴.۷ | فیلد ساختگی از mock schema در UI | ✅ | ⭐ `PolicyFormPage.test.tsx` |
|
||||||
| ۴.۸ | `PolicyFormPage.test.tsx` — عملگر نامعتبر برای نوع نمایش داده نمیشود | ⏳ | |
|
| ۴.۸ | عملگر نامعتبر نمایش داده نمیشود | ✅ | |
|
||||||
|
| ۴.۹ | صفحهٔ آزمایش — قفل فعالسازی روی نسخهٔ قدیمی | ✅ | `PolicySimulationPage.test.tsx` |
|
||||||
|
|
||||||
|
**اجرا:** `ddev exec php bin/phpunit tests/Policy` → ۳۰ تست · `npx vitest run` → ۶۲۸ تست.
|
||||||
|
|
||||||
## ۵. مستندات
|
## ۵. مستندات
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۵.۱ | `docs/api/policy.md` — `simulate`، `policy-templates`، شرط جدید `activate` | ⏳ | |
|
| ۵.۱ | `docs/api/policy.md` — `simulate`، `policy-templates`، شرط تازهٔ `activate` | ✅ | JSON واقعی |
|
||||||
| ۵.۲ | `docs/architecture/policy-engine.md` بخش «چرا آزمایش اجباری است» | ⏳ | ارجاع به ریسک دوم مستند |
|
| ۵.۲ | `policy-engine.md` بخش «چرا آزمایش اجباری است» | ✅ | + سه لایهٔ تضمین و دو حالت مرزی |
|
||||||
|
|
||||||
## ۶. بازبینی پایانی
|
## ۶. بازبینی پایانی
|
||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ✅ | ۷ مورد ⚠️ همه با دلیل |
|
||||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۲۵۰ تست |
|
||||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline |
|
||||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
| ۶.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۲۸ تست؛ **`yarn test` داخل ddev اجرا نمیشود** (باینری esbuild برای darwin نصب شده) — روی هاست اجرا شد |
|
||||||
| ۶.۶ | تستهای tenant سبز | ⏳ | |
|
| ۶.۶ | تستهای tenant سبز | ✅ | |
|
||||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
| ۶.۷ | `docs/api/*` بهروز | ✅ | |
|
||||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
| ۶.۸ | چکلیست UI کامل | ✅ | جز ۳.۱۵ و ۳.۱۹ |
|
||||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | این تسک قرارداد عمومی عوض نمیکند |
|
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ✅ | این تسک هیچ قرارداد عمومیای عوض نکرد؛ همهٔ اندپوینتها پنلمحورند |
|
||||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
||||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
| ۶.۱۱ | موارد بهتعویق با دلیل | ✅ | ۳.۱۵ (بازبینی چشمی) · ۳.۱۹ (diff بصری) · ۴.۲/۴.۵/۴.۶ (پوشش تست) — همه در همین فایل ثبتاند |
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260731065427 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE policy_simulation_runs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, policy_version SMALLINT NOT NULL, sample_size SMALLINT NOT NULL, affected_count SMALLINT NOT NULL, severity VARCHAR(10) NOT NULL, report JSON NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, policy_id INT NOT NULL, run_by INT DEFAULT NULL, UNIQUE INDEX UNIQ_F993F7C8D17F50A6 (uuid), INDEX IDX_F993F7C82D29E3C6 (policy_id), INDEX IDX_F993F7C84114BD6 (run_by), INDEX idx_psr_policy (policy_id, policy_version, created_at), INDEX idx_psr_tenant (entity_type, entity_id, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE policy_simulation_runs DROP FOREIGN KEY FK_F993F7C82D29E3C6');
|
||||||
|
$this->addSql('ALTER TABLE policy_simulation_runs DROP FOREIGN KEY FK_F993F7C84114BD6');
|
||||||
|
$this->addSql('DROP TABLE policy_simulation_runs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اجراهای آزمایشیِ قدیمی ارزشی ندارند — جز **آخرینِ هر (قانون، نسخه)**.
|
||||||
|
*
|
||||||
|
* آن یکی حذفنشدنی است چون `activate` به وجودش وابسته است: پاک کردنش یعنی قانونی که
|
||||||
|
* دیروز آزمایش شده امروز دیگر فعالشدنی نیست، بدون هیچ توضیحی برای کاربر.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'app:policy:prune-simulations', description: 'Delete old policy simulation runs, keeping the latest per policy version.')]
|
||||||
|
class PruneSimulationsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Connection $connection)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('days', null, InputOption::VALUE_REQUIRED, 'Delete runs older than this many days', '90')
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be deleted without deleting');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$days = max(1, (int) $input->getOption('days'));
|
||||||
|
$before = time() - $days * 86400;
|
||||||
|
|
||||||
|
$sql = <<<'SQL'
|
||||||
|
SELECT r.id
|
||||||
|
FROM policy_simulation_runs r
|
||||||
|
WHERE r.created_at < :before
|
||||||
|
AND r.id NOT IN (
|
||||||
|
SELECT keep_id FROM (
|
||||||
|
SELECT MAX(id) AS keep_id
|
||||||
|
FROM policy_simulation_runs
|
||||||
|
GROUP BY policy_id, policy_version
|
||||||
|
) AS keepers
|
||||||
|
)
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$ids = $this->connection->fetchFirstColumn($sql, ['before' => $before]);
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
$io->success('هیچ اجرای آزمایشیِ قابل حذفی نیست.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption('dry-run')) {
|
||||||
|
$io->note(sprintf('%d اجرای آزمایشی حذف میشد.', count($ids)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM policy_simulation_runs WHERE id IN (:ids)',
|
||||||
|
['ids' => $ids],
|
||||||
|
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER],
|
||||||
|
);
|
||||||
|
|
||||||
|
$io->success(sprintf('%d اجرای آزمایشی حذف شد.', count($ids)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ use App\ClinicService\Repository\ServiceItemRepository;
|
|||||||
use App\Policy\Entity\Policy;
|
use App\Policy\Entity\Policy;
|
||||||
use App\Policy\Entity\PolicyVersionLog;
|
use App\Policy\Entity\PolicyVersionLog;
|
||||||
use App\Policy\Repository\PolicyRepository;
|
use App\Policy\Repository\PolicyRepository;
|
||||||
|
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||||
|
use App\Policy\Template\PolicyTemplateRegistry;
|
||||||
use App\Policy\Service\ConditionEvaluator;
|
use App\Policy\Service\ConditionEvaluator;
|
||||||
use App\Policy\Service\PolicySchema;
|
use App\Policy\Service\PolicySchema;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
@@ -31,6 +33,8 @@ class PolicyController extends BaseController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PolicyRepository $policies,
|
private readonly PolicyRepository $policies,
|
||||||
private readonly PolicyVersionLogRepository $versions,
|
private readonly PolicyVersionLogRepository $versions,
|
||||||
|
private readonly PolicySimulationRunRepository $simulations,
|
||||||
|
private readonly PolicyTemplateRegistry $templates,
|
||||||
private readonly ConditionEvaluator $evaluator,
|
private readonly ConditionEvaluator $evaluator,
|
||||||
private readonly PolicySchema $schema,
|
private readonly PolicySchema $schema,
|
||||||
private readonly ServiceItemRepository $items,
|
private readonly ServiceItemRepository $items,
|
||||||
@@ -74,7 +78,17 @@ class PolicyController extends BaseController
|
|||||||
{
|
{
|
||||||
$data = json_decode($request->getContent(), true);
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
if (!is_array($data) || !is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
|
if (!is_array($data)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// الگو فقط `category`/`condition`/`effects` را از پیش پر میکند؛ اعتبارسنجی
|
||||||
|
// بعد از آن همان مسیر عادی است، پس الگو نمیتواند قانونِ نامعتبر بسازد.
|
||||||
|
if (is_string($data['template'] ?? null)) {
|
||||||
|
$data = array_merge($data, $this->templates->build($data['template'], $data['values'] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +148,28 @@ class PolicyController extends BaseController
|
|||||||
return $this->success($policy->toArray());
|
return $this->success($policy->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فعالسازی — فقط بعد از یک اجرای آزمایشیِ **همین نسخه**.
|
||||||
|
*
|
||||||
|
* آزمایش نسخهٔ ۱ اجازهٔ فعالسازی نسخهٔ ۲ را نمیدهد: کاربر متن قانون را عوض کرده و
|
||||||
|
* گزارشی که دیده دیگر توصیف این قانون نیست.
|
||||||
|
*/
|
||||||
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
|
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
|
||||||
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
$policy = $this->requirePolicy($user, $uuid)->setActive(true);
|
$policy = $this->requirePolicy($user, $uuid);
|
||||||
|
$run = $this->simulations->latestFor($policy);
|
||||||
|
|
||||||
|
if ($run === null || $run->getPolicyVersion() !== $policy->getVersion()) {
|
||||||
|
return $this->error(
|
||||||
|
ErrorCodes::ERR_VALIDATION_001,
|
||||||
|
'ابتدا قانون را آزمایش کنید و نتیجه را ببینید',
|
||||||
|
422,
|
||||||
|
'simulation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy->setActive(true);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->success($policy->toArray());
|
return $this->success($policy->toArray());
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Controller;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Branch\Service\BranchResolver;
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use App\Policy\Entity\PolicySimulationRun;
|
||||||
|
use App\Policy\Repository\PolicyRepository;
|
||||||
|
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||||
|
use App\Policy\Simulation\PolicySimulator;
|
||||||
|
use App\Policy\Simulation\SimulationSampler;
|
||||||
|
use App\Policy\Template\PolicyTemplateRegistry;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Shared\Exception\AppException;
|
||||||
|
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[OA\Tag(name: 'Policy')]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
class PolicySimulationController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PolicyRepository $policies,
|
||||||
|
private readonly PolicySimulationRunRepository $runs,
|
||||||
|
private readonly PolicySimulator $simulator,
|
||||||
|
private readonly PolicyTemplateRegistry $templates,
|
||||||
|
private readonly BranchResolver $branches,
|
||||||
|
private readonly TenantOwnershipChecker $ownership,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** الگوهای آمادهٔ قانون — ورودیِ فرم ساخت. */
|
||||||
|
#[Route('/api/v1/policy-templates', name: 'policy_templates', methods: ['GET'])]
|
||||||
|
public function templates(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success($this->templates->describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اجرای آزمایشی روی نوبتهای واقعی گذشته. هیچ چیزی جز خودِ نتیجه ثبت نمیشود.
|
||||||
|
*/
|
||||||
|
#[Route('/api/v1/policy/{uuid}/simulate', name: 'policy_simulate', methods: ['POST'])]
|
||||||
|
public function simulate(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$policy = $this->requirePolicy($user, $uuid);
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
$size = is_array($data) && is_numeric($data['sample_size'] ?? null)
|
||||||
|
? (int) $data['sample_size']
|
||||||
|
: SimulationSampler::DEFAULT_SIZE;
|
||||||
|
|
||||||
|
// سقف صریح است نه بیصدا: کاربری که ۵۰۰ خواسته باید بداند ۵۰ گرفته.
|
||||||
|
if ($size < 1 || $size > SimulationSampler::MAX_SIZE) {
|
||||||
|
return $this->error(
|
||||||
|
ErrorCodes::ERR_VALIDATION_001,
|
||||||
|
sprintf('اندازهٔ نمونه باید بین ۱ و %d باشد', SimulationSampler::MAX_SIZE),
|
||||||
|
422,
|
||||||
|
'sample_size',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $this->simulator->simulate($policy, $size, $user);
|
||||||
|
|
||||||
|
return $this->success($run->toArray(), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تاریخچهٔ اجراهای آزمایشی یک قانون. */
|
||||||
|
#[Route('/api/v1/policy/{uuid}/simulations', name: 'policy_simulations', methods: ['GET'])]
|
||||||
|
public function history(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$policy = $this->requirePolicy($user, $uuid);
|
||||||
|
|
||||||
|
return $this->success(array_map(
|
||||||
|
static fn (PolicySimulationRun $r): array => $r->toArray(),
|
||||||
|
$this->runs->historyFor($policy),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requirePolicy(User $user, string $uuid): Policy
|
||||||
|
{
|
||||||
|
$policy = $this->policies->findByUuid($uuid);
|
||||||
|
[$entityType, $entityId] = $this->branches->pair($user);
|
||||||
|
|
||||||
|
if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) {
|
||||||
|
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Entity;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||||
|
use App\Shared\Tenant\TenantOwnedTrait;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نتیجهٔ یک اجرای آزمایشی — تنها چیزی که شبیهسازی مینویسد.
|
||||||
|
*
|
||||||
|
* وجودش دو کار میکند: به کاربر نشان میدهد قانونش چه میکند، و به `activate` اجازهٔ
|
||||||
|
* فعالسازی میدهد. بدون اجرای آزمایشیِ **همین نسخه**، قانون فعال نمیشود.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: PolicySimulationRunRepository::class)]
|
||||||
|
#[ORM\Table(name: 'policy_simulation_runs')]
|
||||||
|
#[ORM\Index(columns: ['policy_id', 'policy_version', 'created_at'], name: 'idx_psr_policy')]
|
||||||
|
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_psr_tenant')]
|
||||||
|
class PolicySimulationRun
|
||||||
|
{
|
||||||
|
use TenantOwnedTrait;
|
||||||
|
|
||||||
|
public const SEVERITY_NONE = 'none';
|
||||||
|
public const SEVERITY_LOW = 'low';
|
||||||
|
public const SEVERITY_MEDIUM = 'medium';
|
||||||
|
public const SEVERITY_HIGH = 'high';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||||
|
private string $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Policy::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'policy_id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private Policy $policy;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'policy_version', type: 'smallint')]
|
||||||
|
private int $policyVersion;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'sample_size', type: 'smallint')]
|
||||||
|
private int $sampleSize;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'affected_count', type: 'smallint')]
|
||||||
|
private int $affectedCount;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 10)]
|
||||||
|
private string $severity;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $report;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'run_by', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $runBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
|
private int $createdAt;
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $report */
|
||||||
|
public function __construct(
|
||||||
|
Policy $policy,
|
||||||
|
int $sampleSize,
|
||||||
|
int $affectedCount,
|
||||||
|
string $severity,
|
||||||
|
array $report,
|
||||||
|
?User $runBy = null,
|
||||||
|
) {
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
|
$this->policy = $policy;
|
||||||
|
$this->policyVersion = $policy->getVersion();
|
||||||
|
$this->sampleSize = $sampleSize;
|
||||||
|
$this->affectedCount = $affectedCount;
|
||||||
|
$this->severity = $severity;
|
||||||
|
$this->report = $report;
|
||||||
|
$this->runBy = $runBy;
|
||||||
|
$this->createdAt = time();
|
||||||
|
|
||||||
|
$this->entityType = $policy->getEntityType();
|
||||||
|
$this->entityId = $policy->getEntityId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شدت از **نسبت** میآید نه از تعداد: ۷ نوبت از ۱۰ فاجعه است و ۷ از ۵۰۰ عادی.
|
||||||
|
*
|
||||||
|
* صفر هم هشدار است، نه موفقیت: قانونی که روی هیچ نوبتی اثر ندارد یا شرطش هرگز
|
||||||
|
* برقرار نمیشود یا نمونه اشتباه انتخاب شده — هر دو باید دیده شوند.
|
||||||
|
*/
|
||||||
|
public static function severityFor(int $sampleSize, int $affected): string
|
||||||
|
{
|
||||||
|
if ($affected === 0) {
|
||||||
|
return self::SEVERITY_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ratio = $sampleSize === 0 ? 0.0 : $affected / $sampleSize;
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$ratio > 0.60 => self::SEVERITY_HIGH,
|
||||||
|
$ratio > 0.20 => self::SEVERITY_MEDIUM,
|
||||||
|
default => self::SEVERITY_LOW,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getUuid(): string { return $this->uuid; }
|
||||||
|
public function getPolicy(): Policy { return $this->policy; }
|
||||||
|
public function getPolicyVersion(): int { return $this->policyVersion; }
|
||||||
|
public function getSampleSize(): int { return $this->sampleSize; }
|
||||||
|
public function getAffectedCount(): int { return $this->affectedCount; }
|
||||||
|
public function getSeverity(): string { return $this->severity; }
|
||||||
|
public function getCreatedAt(): int { return $this->createdAt; }
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function getReport(): array { return $this->report; }
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'policy_uuid' => $this->policy->getUuid(),
|
||||||
|
'policy_version' => $this->policyVersion,
|
||||||
|
'sample_size' => $this->sampleSize,
|
||||||
|
'affected_count' => $this->affectedCount,
|
||||||
|
'affected_percent' => $this->sampleSize === 0
|
||||||
|
? 0
|
||||||
|
: (int) round($this->affectedCount * 100 / $this->sampleSize),
|
||||||
|
'severity' => $this->severity,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
|
] + $this->report;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Repository;
|
||||||
|
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use App\Policy\Entity\PolicySimulationRun;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<PolicySimulationRun>
|
||||||
|
*/
|
||||||
|
class PolicySimulationRunRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, PolicySimulationRun::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آخرین اجرای آزمایشی این قانون، از هر نسخهای. */
|
||||||
|
public function latestFor(Policy $policy): ?PolicySimulationRun
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('r')
|
||||||
|
->where('r.policy = :policy')
|
||||||
|
->setParameter('policy', $policy)
|
||||||
|
->orderBy('r.createdAt', 'DESC')
|
||||||
|
->addOrderBy('r.id', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return PolicySimulationRun[] */
|
||||||
|
public function historyFor(Policy $policy, int $limit = 10): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('r')
|
||||||
|
->where('r.policy = :policy')
|
||||||
|
->setParameter('policy', $policy)
|
||||||
|
->orderBy('r.createdAt', 'DESC')
|
||||||
|
->addOrderBy('r.id', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(PolicySimulationRun $run): void
|
||||||
|
{
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->persist($run);
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,26 @@ final class PolicyResolver
|
|||||||
return $this->combine($matched);
|
return $this->combine($matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ارزیابی **یک** قانون، بدون رقابت و بدون ترکیب با بقیه.
|
||||||
|
*
|
||||||
|
* سؤال آزمایشگاه این است که «این قانون چه میکند»، نه «نتیجهٔ نهایی با همهٔ قوانین
|
||||||
|
* چه میشود». دومی مفید است ولی چیزی نیست که کاربرِ در حال نوشتن قانون میپرسد.
|
||||||
|
*
|
||||||
|
* دامنه و اعتبار زمانی هم عمداً نادیده گرفته میشوند: کاربر دارد قانونِ **پیشنویس**
|
||||||
|
* را روی نمونهٔ گذشته میآزماید؛ رد کردنش بهخاطر اینکه هنوز فعال نیست بیمعناست.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $facts
|
||||||
|
*/
|
||||||
|
public function evaluateOne(Policy $policy, array $facts): PolicyOutcome
|
||||||
|
{
|
||||||
|
if (!$this->evaluator->matches($policy, $facts)) {
|
||||||
|
return new PolicyOutcome();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->combine([$policy]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* قانونی که دامنهاش با این درخواست نمیخواند اصلاً کاندید نیست.
|
* قانونی که دامنهاش با این درخواست نمیخواند اصلاً کاندید نیست.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -76,6 +76,56 @@ final class PolicySchema
|
|||||||
self::EFFECT_DISCOUNT_RIALS => 'sum',
|
self::EFFECT_DISCOUNT_RIALS => 'sum',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فرادادهٔ هر فیلد: برچسب فارسی، نوع ورودی، و عملگرهایی که **برای همان نوع** معنا
|
||||||
|
* دارند.
|
||||||
|
*
|
||||||
|
* فیلتر شدن عملگرها اختیاری نیست: اگر فرم همهٔ شش عملگر را نشان بدهد، کاربر
|
||||||
|
* `patient_tags > 5` میسازد و ۴۲۲ میگیرد بدون اینکه بفهمد چرا.
|
||||||
|
*/
|
||||||
|
private const FIELD_META = [
|
||||||
|
'item_count' => ['label' => 'تعداد موارد انتخابی', 'type' => 'int'],
|
||||||
|
'item_uuids' => ['label' => 'موارد انتخابی', 'type' => 'list'],
|
||||||
|
'catalog_category' => ['label' => 'دستهٔ کاتالوگ', 'type' => 'uuid'],
|
||||||
|
'service_uuid' => ['label' => 'سرویس', 'type' => 'uuid'],
|
||||||
|
'patient_age' => ['label' => 'سن بیمار', 'type' => 'int'],
|
||||||
|
'patient_gender' => ['label' => 'جنسیت بیمار', 'type' => 'enum', 'values' => ['male', 'female']],
|
||||||
|
'patient_tags' => ['label' => 'برچسبهای بیمار', 'type' => 'list'],
|
||||||
|
'has_parental_consent' => ['label' => 'رضایت والدین', 'type' => 'bool'],
|
||||||
|
'visit_count' => ['label' => 'تعداد ویزیت قبلی', 'type' => 'int'],
|
||||||
|
'subtotal_rials' => ['label' => 'جمع مبلغ (ریال)', 'type' => 'int'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** عملگرهای معنادار برای هر نوع ورودی. */
|
||||||
|
private const OPERATORS_BY_TYPE = [
|
||||||
|
'int' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_LESS_THAN],
|
||||||
|
'uuid' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN],
|
||||||
|
'enum' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN],
|
||||||
|
'bool' => [self::OP_EQUALS],
|
||||||
|
'list' => [self::OP_CONTAINS],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** برچسب فارسیِ هر اثر — همان چیزی که در فرم دیده میشود. */
|
||||||
|
private const EFFECT_META = [
|
||||||
|
self::EFFECT_FORBID => ['label' => 'ممنوع کن', 'value_type' => 'none'],
|
||||||
|
self::EFFECT_REQUIRE_RESOURCE => ['label' => 'نیاز به نقش', 'value_type' => 'string'],
|
||||||
|
self::EFFECT_REQUIRE_FLAG => ['label' => 'نیاز به تأیید', 'value_type' => 'string'],
|
||||||
|
self::EFFECT_MIN_DURATION => ['label' => 'حداقل مدت (دقیقه)', 'value_type' => 'int'],
|
||||||
|
self::EFFECT_ADD_DURATION => ['label' => 'افزودن مدت (دقیقه)', 'value_type' => 'int'],
|
||||||
|
self::EFFECT_MIN_DAYS_BETWEEN => ['label' => 'حداقل فاصله (روز)', 'value_type' => 'int'],
|
||||||
|
self::EFFECT_DISCOUNT_PERCENT => ['label' => 'تخفیف درصدی', 'value_type' => 'int'],
|
||||||
|
self::EFFECT_DISCOUNT_RIALS => ['label' => 'تخفیف مبلغی (ریال)', 'value_type' => 'int'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const CATEGORY_LABELS = [
|
||||||
|
Policy::CATEGORY_SELECTION => 'انتخاب خدمات',
|
||||||
|
Policy::CATEGORY_ELIGIBILITY => 'صلاحیت بیمار',
|
||||||
|
Policy::CATEGORY_RESOURCE => 'منابع لازم',
|
||||||
|
Policy::CATEGORY_TIMING => 'مدت نوبت',
|
||||||
|
Policy::CATEGORY_SPACING => 'فاصلهٔ جلسات',
|
||||||
|
Policy::CATEGORY_PRICING => 'قیمت و تخفیف',
|
||||||
|
];
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function describe(): array
|
public function describe(): array
|
||||||
{
|
{
|
||||||
@@ -83,10 +133,18 @@ final class PolicySchema
|
|||||||
|
|
||||||
foreach (Policy::CATEGORIES as $category) {
|
foreach (Policy::CATEGORIES as $category) {
|
||||||
$out[$category] = [
|
$out[$category] = [
|
||||||
'fields' => self::FIELDS[$category],
|
'label' => self::CATEGORY_LABELS[$category],
|
||||||
'operators' => self::OPERATORS,
|
'fields' => self::FIELDS[$category],
|
||||||
'effects' => array_map(
|
'operators' => self::OPERATORS,
|
||||||
static fn (string $effect): array => [
|
'field_meta' => array_map(
|
||||||
|
static fn (string $field): array => self::FIELD_META[$field] + [
|
||||||
|
'key' => $field,
|
||||||
|
'operators' => self::OPERATORS_BY_TYPE[self::FIELD_META[$field]['type']],
|
||||||
|
],
|
||||||
|
self::FIELDS[$category],
|
||||||
|
),
|
||||||
|
'effects' => array_map(
|
||||||
|
static fn (string $effect): array => self::EFFECT_META[$effect] + [
|
||||||
'type' => $effect,
|
'type' => $effect,
|
||||||
'combination' => self::COMBINATION[$effect],
|
'combination' => self::COMBINATION[$effect],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Simulation;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use App\Policy\Entity\PolicySimulationRun;
|
||||||
|
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||||
|
use App\Policy\Service\PolicyResolver;
|
||||||
|
use App\Policy\Service\PolicySchema;
|
||||||
|
use App\Policy\ValueObject\PolicyOutcome;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اجرای آزمایشی یک قانون روی نوبتهای واقعیِ گذشته — بدون نوشتن هیچ چیز.
|
||||||
|
*
|
||||||
|
* ## تضمین «چیزی ثبت نمیشود»، سه لایه
|
||||||
|
*
|
||||||
|
* ۱. ارزیابی روی **حقایق** انجام میشود نه روی entity؛ هیچ entity ای تغییر نمیکند.
|
||||||
|
* ۲. کل اجرا داخل تراکنشی است که در `finally` **همیشه** rollback و `clear` میشود —
|
||||||
|
* حتی اگر روزی کسی سهواً یک `flush` اضافه کند.
|
||||||
|
* ۳. `PolicySimulationRunTest` تعداد ردیف جدولهای حساس را قبل و بعد میشمارد.
|
||||||
|
*
|
||||||
|
* ثبت خودِ `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش انجام میشود.
|
||||||
|
*/
|
||||||
|
final class PolicySimulator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SimulationSampler $sampler,
|
||||||
|
private readonly SimulationFacts $facts,
|
||||||
|
private readonly PolicyResolver $policies,
|
||||||
|
private readonly PolicySimulationRunRepository $runs,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function simulate(Policy $policy, int $size = SimulationSampler::DEFAULT_SIZE, ?User $runBy = null): PolicySimulationRun
|
||||||
|
{
|
||||||
|
$this->em->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = $this->runInternal($policy, $size);
|
||||||
|
} finally {
|
||||||
|
$this->em->rollback();
|
||||||
|
// بدون `clear`، entity های لمسشده در identity map میمانند و اولین flushِ
|
||||||
|
// بعدی در همین request آنها را ثبت میکند — باگی که پیدا کردنش روزها میبرد.
|
||||||
|
$this->em->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `clear` ارجاعهای قبلی را از EM جدا کرده؛ قانون باید دوباره خوانده شود.
|
||||||
|
$policy = $this->em->getRepository(Policy::class)->find($policy->getId());
|
||||||
|
|
||||||
|
if ($policy === null) {
|
||||||
|
throw new \LogicException('Policy vanished during simulation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = new PolicySimulationRun(
|
||||||
|
$policy,
|
||||||
|
$report['sample_size'],
|
||||||
|
count($report['rows']),
|
||||||
|
PolicySimulationRun::severityFor($report['sample_size'], count($report['rows'])),
|
||||||
|
['rows' => $report['rows'], 'warning' => $report['warning']],
|
||||||
|
$runBy === null ? null : $this->em->getRepository(User::class)->find($runBy->getId()),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->runs->save($run);
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sample_size: int, rows: list<array<string, mixed>>, warning: string|null}
|
||||||
|
*/
|
||||||
|
private function runInternal(Policy $policy, int $size): array
|
||||||
|
{
|
||||||
|
$sample = $this->sampler->recentAppointments($policy, $size);
|
||||||
|
|
||||||
|
if ($sample === []) {
|
||||||
|
// کلینیک تازه هیچ نوبت گذشتهای ندارد؛ اگر این حالت خطا بود، هرگز
|
||||||
|
// نمیتوانست قانونی فعال کند.
|
||||||
|
return ['sample_size' => 0, 'rows' => [], 'warning' => 'دادهای برای آزمایش نیست'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($sample as $appointment) {
|
||||||
|
$outcome = $this->policies->evaluateOne(
|
||||||
|
$policy,
|
||||||
|
$this->facts->forAppointment($appointment, $policy->getCategory()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($outcome->appliedPolicies === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->describe($policy, $appointment, $outcome);
|
||||||
|
|
||||||
|
if ($row !== null) {
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['sample_size' => count($sample), 'rows' => $rows, 'warning' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاوت «وضعیت فعلی → با این قانون» به زبان کاربر.
|
||||||
|
*
|
||||||
|
* تنها ستونی است که کاربر غیرفنی میفهمد، پس عمداً متن است نه ساختار خام اثر.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null `null` یعنی این نوبت عملاً تغییری نمیکرد
|
||||||
|
*/
|
||||||
|
private function describe(Policy $policy, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||||
|
{
|
||||||
|
$base = [
|
||||||
|
'appointment_uuid' => $appointment->getUuid(),
|
||||||
|
'patient_name' => $appointment->getPatientName() ?? '—',
|
||||||
|
'slot_start' => $appointment->getSlotStart(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($outcome->isForbidden()) {
|
||||||
|
return $base + [
|
||||||
|
'before' => 'مجاز',
|
||||||
|
'after' => 'رد میشد',
|
||||||
|
'reason' => implode(' ', $outcome->forbidReasons),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($policy->getCategory()) {
|
||||||
|
Policy::CATEGORY_TIMING => $this->describeTiming($base, $appointment, $outcome),
|
||||||
|
Policy::CATEGORY_PRICING => $this->describePricing($base, $appointment, $outcome),
|
||||||
|
Policy::CATEGORY_RESOURCE => $this->describeList(
|
||||||
|
$base,
|
||||||
|
'منبع لازم',
|
||||||
|
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []),
|
||||||
|
),
|
||||||
|
Policy::CATEGORY_ELIGIBILITY => $this->describeList(
|
||||||
|
$base,
|
||||||
|
'تأیید لازم',
|
||||||
|
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []),
|
||||||
|
),
|
||||||
|
Policy::CATEGORY_SPACING => $this->describeSpacing($base, $outcome),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $base
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function describeTiming(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||||
|
{
|
||||||
|
$current = $this->facts->durationOf($appointment);
|
||||||
|
$target = max(
|
||||||
|
$current + (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0),
|
||||||
|
(int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($target === $current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base + [
|
||||||
|
'before' => sprintf('%d دقیقه', $current),
|
||||||
|
'after' => sprintf('%d دقیقه', $target),
|
||||||
|
'reason' => sprintf('%+d دقیقه', $target - $current),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $base
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function describePricing(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||||
|
{
|
||||||
|
$subtotal = $this->facts->subtotalOf($appointment);
|
||||||
|
|
||||||
|
$discount = (int) floor($subtotal * (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0) / 100)
|
||||||
|
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
|
||||||
|
|
||||||
|
$discount = min($discount, $subtotal);
|
||||||
|
|
||||||
|
if ($discount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base + [
|
||||||
|
'before' => sprintf('%s ریال', number_format($subtotal)),
|
||||||
|
'after' => sprintf('%s ریال', number_format($subtotal - $discount)),
|
||||||
|
'reason' => sprintf('%s ریال تخفیف', number_format($discount)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $base
|
||||||
|
* @param array<int, mixed> $values
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function describeList(array $base, string $label, array $values): ?array
|
||||||
|
{
|
||||||
|
$values = array_values(array_filter($values, 'is_string'));
|
||||||
|
|
||||||
|
if ($values === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base + [
|
||||||
|
'before' => 'بدون قید',
|
||||||
|
'after' => sprintf('%s: %s', $label, implode('، ', $values)),
|
||||||
|
'reason' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $base
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function describeSpacing(array $base, PolicyOutcome $outcome): ?array
|
||||||
|
{
|
||||||
|
$days = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base + [
|
||||||
|
'before' => 'بدون حداقل فاصله',
|
||||||
|
'after' => sprintf('حداقل %d روز فاصله', $days),
|
||||||
|
'reason' => sprintf('%d روز', $days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Simulation;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use App\UserProfile\Entity\UserProfile;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حقایق یک نوبتِ ثبتشده، به همان شکلی که نقاط اجرای زنده میسازند.
|
||||||
|
*
|
||||||
|
* اگر این کلاس حقیقتی را طور دیگری بسازد، آزمایش دروغ میگوید — و آزمایشی که دروغ
|
||||||
|
* بگوید بدتر از نداشتن آزمایش است. به همین دلیل نامها عیناً از
|
||||||
|
* {@see \App\Policy\Service\PolicySchema::FIELDS} میآیند.
|
||||||
|
*/
|
||||||
|
final class SimulationFacts
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function forAppointment(Appointment $appointment, string $category): array
|
||||||
|
{
|
||||||
|
$service = $appointment->getServiceItem();
|
||||||
|
$items = $appointment->getServiceItems()->count();
|
||||||
|
|
||||||
|
$common = [
|
||||||
|
'service_uuid' => $service?->getUuid(),
|
||||||
|
'catalog_category' => $service?->getCatalogCategory()?->getUuid(),
|
||||||
|
'item_count' => max(1, $items),
|
||||||
|
];
|
||||||
|
|
||||||
|
return match ($category) {
|
||||||
|
Policy::CATEGORY_SELECTION => $common + [
|
||||||
|
'item_uuids' => $this->itemUuids($appointment),
|
||||||
|
],
|
||||||
|
Policy::CATEGORY_ELIGIBILITY => $common + $this->patientFacts($appointment),
|
||||||
|
Policy::CATEGORY_TIMING => $common + [
|
||||||
|
'patient_age' => $this->patientFacts($appointment)['patient_age'],
|
||||||
|
],
|
||||||
|
Policy::CATEGORY_PRICING => $common + [
|
||||||
|
'subtotal_rials' => $this->subtotalOf($appointment),
|
||||||
|
'patient_tags' => [],
|
||||||
|
'visit_count' => $this->visitCount($appointment),
|
||||||
|
],
|
||||||
|
default => $common,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function itemUuids(Appointment $appointment): array
|
||||||
|
{
|
||||||
|
$uuids = [];
|
||||||
|
|
||||||
|
foreach ($appointment->getServiceItems() as $item) {
|
||||||
|
$uuids[] = $item->getUuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uuids === [] && $appointment->getServiceItem() !== null) {
|
||||||
|
$uuids[] = $appointment->getServiceItem()->getUuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uuids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{patient_age: int|null, patient_gender: string|null, patient_tags: list<string>, visit_count: int, has_parental_consent: bool} */
|
||||||
|
private function patientFacts(Appointment $appointment): array
|
||||||
|
{
|
||||||
|
/** @var UserProfile|null $profile */
|
||||||
|
$profile = $this->em->getRepository(UserProfile::class)
|
||||||
|
->findOneBy(['user' => $appointment->getUser()]);
|
||||||
|
|
||||||
|
$dob = $profile?->getDateOfBirth();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'patient_age' => $dob === null || $dob <= 0
|
||||||
|
? null
|
||||||
|
: (int) floor(($appointment->getSlotStart() - $dob) / 31556952),
|
||||||
|
'patient_gender' => $profile?->getGender() ?? $appointment->getPatientGender(),
|
||||||
|
'patient_tags' => [],
|
||||||
|
'visit_count' => $this->visitCount($appointment),
|
||||||
|
// نوبت گذشته پرچمِ لحظهای ندارد؛ فرضِ «نگرفته» محافظهکارانه است و
|
||||||
|
// باعث میشود قانون `require_flag` در گزارش **دیده** شود نه پنهان.
|
||||||
|
'has_parental_consent' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visitCount(Appointment $appointment): int
|
||||||
|
{
|
||||||
|
return (int) $this->em->createQueryBuilder()
|
||||||
|
->select('COUNT(a.id)')
|
||||||
|
->from(Appointment::class, 'a')
|
||||||
|
->where('a.user = :user')
|
||||||
|
->andWhere('a.slotStart < :before')
|
||||||
|
->andWhere('a.status = :status')
|
||||||
|
->setParameter('user', $appointment->getUser())
|
||||||
|
->setParameter('before', $appointment->getSlotStart())
|
||||||
|
->setParameter('status', Appointment::STATUS_COMPLETED)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مبلغ ثبتشدهٔ همان نوبت؛ نه قیمت امروزِ سرویس. */
|
||||||
|
public function subtotalOf(Appointment $appointment): int
|
||||||
|
{
|
||||||
|
return (int) ($appointment->getVisitPriceRials()
|
||||||
|
?? $appointment->getServiceItem()?->getPriceRials()
|
||||||
|
?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مدت ثبتشدهٔ همان نوبت، با بازگشت به طول بازهٔ اسلات. */
|
||||||
|
public function durationOf(Appointment $appointment): int
|
||||||
|
{
|
||||||
|
return (int) ($appointment->getServiceTotalMinutes()
|
||||||
|
?? max(0, intdiv($appointment->getSlotEnd() - $appointment->getSlotStart(), 60)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Simulation;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمونهٔ نوبتهای واقعیِ گذشته برای آزمایش یک قانون.
|
||||||
|
*
|
||||||
|
* نمونه به **دامنهٔ خود قانون** محدود میشود: قانون لیزر روی ۵۰ نوبت دندانپزشکی
|
||||||
|
* «۰٪ تحت تأثیر» میدهد، و آن عدد گمراهکنندهتر از نداشتن گزارش است.
|
||||||
|
*/
|
||||||
|
final class SimulationSampler
|
||||||
|
{
|
||||||
|
public const DEFAULT_SIZE = 50;
|
||||||
|
/** سقف نمونه — گزارش بزرگتر نه خوانده میشود نه در `report` جا میشود. */
|
||||||
|
public const MAX_SIZE = 50;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return Appointment[] جدیدترین اول */
|
||||||
|
public function recentAppointments(Policy $policy, int $size = self::DEFAULT_SIZE): array
|
||||||
|
{
|
||||||
|
$size = max(1, min($size, self::MAX_SIZE));
|
||||||
|
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select('a')
|
||||||
|
->from(Appointment::class, 'a')
|
||||||
|
->where('a.entityType = :type')
|
||||||
|
->andWhere('a.entityId = :id')
|
||||||
|
->andWhere('a.status IN (:statuses)')
|
||||||
|
->setParameter('type', $policy->getEntityType())
|
||||||
|
->setParameter('id', $policy->getEntityId())
|
||||||
|
->setParameter('statuses', [Appointment::STATUS_CONFIRMED, Appointment::STATUS_COMPLETED])
|
||||||
|
->orderBy('a.slotStart', 'DESC')
|
||||||
|
->setMaxResults($size);
|
||||||
|
|
||||||
|
if ($policy->getAddress() !== null) {
|
||||||
|
$qb->andWhere('a.addressId = :address')->setParameter('address', $policy->getAddress()->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policy->getServiceItem() !== null) {
|
||||||
|
$qb->andWhere('a.serviceItem = :service')->setParameter('service', $policy->getServiceItem());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policy->getCatalogCategory() !== null) {
|
||||||
|
$qb->join('a.serviceItem', 'si')
|
||||||
|
->andWhere('si.catalogCategory = :category')
|
||||||
|
->setParameter('category', $policy->getCatalogCategory());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policy\Template;
|
||||||
|
|
||||||
|
use App\Policy\Entity\Policy;
|
||||||
|
use App\Policy\Service\PolicySchema;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Exception\AppException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الگوهای آمادهٔ قانون — راهِ ۹۰٪ کاربران.
|
||||||
|
*
|
||||||
|
* کاربر غیرفنی نباید شرط خام بنویسد: الگو را انتخاب میکند، دو-سه مقدار پر میکند، و
|
||||||
|
* `condition`/`effects` درست از همینجا ساخته میشود. حالت پیشرفته برای بقیه است.
|
||||||
|
*
|
||||||
|
* الگو **جایگزین** اعتبارسنجی نیست؛ خروجیاش هم از همان `ConditionEvaluator` رد میشود.
|
||||||
|
*/
|
||||||
|
final class PolicyTemplateRegistry
|
||||||
|
{
|
||||||
|
private const TEMPLATES = [
|
||||||
|
'min_days_between_sessions' => [
|
||||||
|
'title' => 'حداقل فاصله بین جلسات',
|
||||||
|
'description' => 'بین دو جلسهٔ یک خدمت، حداقل چند روز فاصله باشد.',
|
||||||
|
'category' => Policy::CATEGORY_SPACING,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'days', 'type' => 'int', 'label' => 'حداقل روز', 'min' => 1, 'max' => 365],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'complex_min_duration' => [
|
||||||
|
'title' => 'حداقل مدت نوبت',
|
||||||
|
'description' => 'نوبت این خدمت کمتر از این مقدار نباشد.',
|
||||||
|
'category' => Policy::CATEGORY_TIMING,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'minutes', 'type' => 'int', 'label' => 'حداقل دقیقه', 'min' => 5, 'max' => 480],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'extra_time_for_many_items' => [
|
||||||
|
'title' => 'زمان اضافه برای انتخابهای پرتعداد',
|
||||||
|
'description' => 'وقتی بیمار بیش از N مورد انتخاب کند، به مدت نوبت اضافه شود.',
|
||||||
|
'category' => Policy::CATEGORY_TIMING,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'item_count', 'type' => 'int', 'label' => 'بیشتر از چند مورد', 'min' => 1, 'max' => 20],
|
||||||
|
['key' => 'minutes', 'type' => 'int', 'label' => 'دقیقهٔ اضافه', 'min' => 5, 'max' => 120],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'surgery_needs_surgeon' => [
|
||||||
|
'title' => 'نیاز به نقش خاص',
|
||||||
|
'description' => 'این خدمت بدون حضور نقش مشخصی انجام نشود.',
|
||||||
|
'category' => Policy::CATEGORY_RESOURCE,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'role', 'type' => 'resource_type_select', 'label' => 'نقش لازم'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'minor_needs_consent' => [
|
||||||
|
'title' => 'رضایت والدین برای زیر سن قانونی',
|
||||||
|
'description' => 'بیمار زیر سن مشخص، بدون تأیید رضایت والدین نوبت نگیرد.',
|
||||||
|
'category' => Policy::CATEGORY_ELIGIBILITY,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'age', 'type' => 'int', 'label' => 'سن مرزی', 'min' => 1, 'max' => 100],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'vip_discount' => [
|
||||||
|
'title' => 'تخفیف بیمار وفادار',
|
||||||
|
'description' => 'بیمارانی که بیش از N ویزیت داشتهاند، درصدی تخفیف بگیرند.',
|
||||||
|
'category' => Policy::CATEGORY_PRICING,
|
||||||
|
'inputs' => [
|
||||||
|
['key' => 'visit_count', 'type' => 'int', 'label' => 'بیشتر از چند ویزیت', 'min' => 1, 'max' => 100],
|
||||||
|
['key' => 'percent', 'type' => 'int', 'label' => 'درصد تخفیف', 'min' => 1, 'max' => 100],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
public function describe(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach (self::TEMPLATES as $key => $template) {
|
||||||
|
$out[] = ['key' => $key] + $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $values
|
||||||
|
* @return array{category: string, condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function build(string $key, array $values): array
|
||||||
|
{
|
||||||
|
$template = self::TEMPLATES[$key] ?? null;
|
||||||
|
|
||||||
|
if ($template === null) {
|
||||||
|
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمیشود', 422, 'template');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($template['inputs'] as $input) {
|
||||||
|
if ($input['type'] === 'int' && !is_numeric($values[$input['key']] ?? null)) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCodes::ERR_VALIDATION_002,
|
||||||
|
sprintf('مقدار «%s» الزامی است', $input['label']),
|
||||||
|
422,
|
||||||
|
$input['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['category' => $template['category']] + $this->contentFor($key, $values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $v
|
||||||
|
* @return array{condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private function contentFor(string $key, array $v): array
|
||||||
|
{
|
||||||
|
return match ($key) {
|
||||||
|
'min_days_between_sessions' => [
|
||||||
|
'condition' => [],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 'value' => (int) $v['days']]],
|
||||||
|
],
|
||||||
|
'complex_min_duration' => [
|
||||||
|
'condition' => [],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_MIN_DURATION, 'value' => (int) $v['minutes']]],
|
||||||
|
],
|
||||||
|
'extra_time_for_many_items' => [
|
||||||
|
'condition' => ['match' => 'all', 'conditions' => [
|
||||||
|
['field' => 'item_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['item_count']],
|
||||||
|
]],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_ADD_DURATION, 'value' => (int) $v['minutes']]],
|
||||||
|
],
|
||||||
|
'surgery_needs_surgeon' => [
|
||||||
|
'condition' => [],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_RESOURCE, 'value' => (string) ($v['role'] ?? '')]],
|
||||||
|
],
|
||||||
|
'minor_needs_consent' => [
|
||||||
|
'condition' => ['match' => 'all', 'conditions' => [
|
||||||
|
['field' => 'patient_age', 'operator' => PolicySchema::OP_LESS_THAN, 'value' => (int) $v['age']],
|
||||||
|
]],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_FLAG, 'value' => 'has_parental_consent']],
|
||||||
|
],
|
||||||
|
'vip_discount' => [
|
||||||
|
'condition' => ['match' => 'all', 'conditions' => [
|
||||||
|
['field' => 'visit_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['visit_count']],
|
||||||
|
]],
|
||||||
|
'effects' => [['type' => PolicySchema::EFFECT_DISCOUNT_PERCENT, 'value' => (int) $v['percent']]],
|
||||||
|
],
|
||||||
|
default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمیشود', 422, 'template'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,10 @@ class PolicyEngineTest extends ApiTestCase
|
|||||||
return $created['data'];
|
return $created['data'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فعالسازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** میخواهد.
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/simulate", $user);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
|
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
|
||||||
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
|
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
@@ -128,6 +132,15 @@ class PolicyEngineTest extends ApiTestCase
|
|||||||
|
|
||||||
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بستهبودن نکتهٔ اصلی شِماست.
|
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بستهبودن نکتهٔ اصلی شِماست.
|
||||||
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
|
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
|
||||||
|
|
||||||
|
// فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» میسازد و
|
||||||
|
// ۴۲۲ میگیرد بیآنکه بفهمد چرا.
|
||||||
|
$meta = array_column($schema['eligibility']['field_meta'], null, 'key');
|
||||||
|
|
||||||
|
self::assertSame('int', $meta['patient_age']['type']);
|
||||||
|
self::assertSame(['equals', 'not_equals', 'greater_than', 'less_than'], $meta['patient_age']['operators']);
|
||||||
|
self::assertSame(['contains'], $meta['patient_tags']['operators']);
|
||||||
|
self::assertSame('سن بیمار', $meta['patient_age']['label']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
|
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||||||
@@ -390,6 +403,8 @@ class PolicyEngineTest extends ApiTestCase
|
|||||||
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
||||||
self::assertSame(2, $updated['data']['version']);
|
self::assertSame(2, $updated['data']['version']);
|
||||||
|
|
||||||
|
// نسخهٔ تازه فعال میماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود.
|
||||||
|
|
||||||
$second = $this->quote($user, $service, $address);
|
$second = $this->quote($user, $service, $address);
|
||||||
self::assertSame(200_000, $second['data']['discount_rials']);
|
self::assertSame(200_000, $second['data']['discount_rials']);
|
||||||
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Policy;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Clinic\Entity\Clinic;
|
||||||
|
use App\ClinicService\Entity\ServiceItem;
|
||||||
|
use App\ClinicService\Entity\ServiceSection;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Doctor\Entity\DoctorAddress;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آزمایشگاه قانون — تسک ۱۰.
|
||||||
|
*
|
||||||
|
* مهمترین تستِ این فایل `testSimulationWritesNothingButItsOwnRun` است: هر بار که کسی
|
||||||
|
* `PolicySimulator` را عوض کند، همان تست جلوی نوشتنِ ناخواسته را میگیرد.
|
||||||
|
*/
|
||||||
|
class PolicySimulationTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
|
||||||
|
private function clinicWithBranch(): array
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||||
|
$clinic = new Clinic($user);
|
||||||
|
$clinic->setName('کلینیک آزمایشگاه');
|
||||||
|
$this->em->persist($clinic);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||||||
|
$this->em->persist($section);
|
||||||
|
|
||||||
|
$address = DoctorAddress::forClinic($clinic->getId());
|
||||||
|
$address->setName('شعبهٔ مرکزی');
|
||||||
|
$this->em->persist($address);
|
||||||
|
|
||||||
|
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($doctorUser, 'دکتر آزمون');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return [$user, $section, $address, $doctor];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(ServiceSection $section, string $name, int $price = 1_000_000): ServiceItem
|
||||||
|
{
|
||||||
|
$item = new ServiceItem($section, $name);
|
||||||
|
$item->setSoloDurationMinutes(20);
|
||||||
|
$item->setPriceRials($price);
|
||||||
|
$this->em->persist($item);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نوبت گذشتهٔ ثبتشده — نمونهٔ آزمایش از همینها ساخته میشود. */
|
||||||
|
private function pastAppointment(
|
||||||
|
Doctor $doctor,
|
||||||
|
User $patient,
|
||||||
|
ServiceItem $service,
|
||||||
|
Clinic|int $clinicId,
|
||||||
|
int $daysAgo,
|
||||||
|
int $price = 1_000_000,
|
||||||
|
): Appointment {
|
||||||
|
$start = time() - $daysAgo * 86400;
|
||||||
|
|
||||||
|
$appointment = new Appointment($doctor, $patient, $start, $start + 1200);
|
||||||
|
$appointment->assignTenantPair('clinic', is_int($clinicId) ? $clinicId : (int) $clinicId->getId());
|
||||||
|
$appointment->setServiceItem($service);
|
||||||
|
$appointment->setVisitPriceRials($price);
|
||||||
|
$appointment->setPatientName('بیمار نمونه');
|
||||||
|
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||||
|
$appointment->transitionTo(Appointment::STATUS_COMPLETED);
|
||||||
|
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $body */
|
||||||
|
private function draft(User $user, array $body): array
|
||||||
|
{
|
||||||
|
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
|
||||||
|
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
return $created['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param string[] $tables */
|
||||||
|
private function countRows(array $tables): array
|
||||||
|
{
|
||||||
|
$connection = $this->em->getConnection();
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$counts[$table] = (int) $connection->fetchOne("SELECT COUNT(*) FROM $table");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── الگوها ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testTemplatesAreListedWithTheirInputs(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||||
|
$body = $this->authJson('GET', '/api/v1/policy-templates', $user);
|
||||||
|
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$keys = array_column($body['data'], 'key');
|
||||||
|
|
||||||
|
self::assertContains('min_days_between_sessions', $keys);
|
||||||
|
self::assertContains('vip_discount', $keys);
|
||||||
|
|
||||||
|
$vip = current(array_filter($body['data'], static fn (array $t): bool => $t['key'] === 'vip_discount'));
|
||||||
|
|
||||||
|
self::assertSame('pricing', $vip['category']);
|
||||||
|
self::assertSame(['visit_count', 'percent'], array_column($vip['inputs'], 'key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** الگو باید همان قانونی را بسازد که کاربر دستی میساخت — نه چیز دیگری. */
|
||||||
|
public function testTemplateBuildsAValidPolicy(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'name' => 'تخفیف مشتری وفادار',
|
||||||
|
'template' => 'vip_discount',
|
||||||
|
'values' => ['visit_count' => 3, 'percent' => 15],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame('pricing', $policy['category']);
|
||||||
|
self::assertSame(
|
||||||
|
[['field' => 'visit_count', 'operator' => 'greater_than', 'value' => 3]],
|
||||||
|
$policy['condition']['conditions'],
|
||||||
|
);
|
||||||
|
self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTemplateWithAMissingValueIsRejected(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/policy', $user, [
|
||||||
|
'name' => 'بدون مقدار',
|
||||||
|
'template' => 'vip_discount',
|
||||||
|
'values' => ['visit_count' => 3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── شبیهسازی ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** ⭐ ارزشمندترین تست این تسک. */
|
||||||
|
public function testSimulationWritesNothingButItsOwnRun(): void
|
||||||
|
{
|
||||||
|
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||||
|
$service = $this->service($section, 'لیزر');
|
||||||
|
$clinic = $address->getClinicId();
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 3; $i++) {
|
||||||
|
$this->pastAppointment($doctor, $user, $service, (int) $clinic, $i * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'pricing',
|
||||||
|
'name' => 'تخفیف ۱۰٪',
|
||||||
|
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tables = ['appointments', 'price_snapshots', 'resource_occupancy', 'policies', 'policy_version_logs'];
|
||||||
|
$before = $this->countRows($tables);
|
||||||
|
$runsBefore = $this->countRows(['policy_simulation_runs'])['policy_simulation_runs'];
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
self::assertSame($before, $this->countRows($tables), 'شبیهسازی نباید هیچ ردیفی بنویسد');
|
||||||
|
|
||||||
|
// دیتابیس تست هرگز ریست نمیشود، پس تفاوت شمرده میشود نه مقدار مطلق.
|
||||||
|
self::assertSame(
|
||||||
|
$runsBefore + 1,
|
||||||
|
$this->countRows(['policy_simulation_runs'])['policy_simulation_runs'],
|
||||||
|
'تنها ردیفی که باید نوشته شود، خودِ نتیجهٔ آزمایش است',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPricingSimulationShowsThePerAppointmentDifference(): void
|
||||||
|
{
|
||||||
|
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||||
|
$service = $this->service($section, 'فیلر');
|
||||||
|
|
||||||
|
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 5, 2_000_000);
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'pricing',
|
||||||
|
'name' => 'تخفیف ۲۵٪',
|
||||||
|
'effects' => [['type' => 'discount_percent', 'value' => 25]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
|
||||||
|
self::assertSame(1, $body['data']['sample_size']);
|
||||||
|
self::assertSame(1, $body['data']['affected_count']);
|
||||||
|
self::assertSame(100, $body['data']['affected_percent']);
|
||||||
|
self::assertSame('high', $body['data']['severity']);
|
||||||
|
|
||||||
|
$row = $body['data']['rows'][0];
|
||||||
|
|
||||||
|
self::assertSame('2,000,000 ریال', $row['before']);
|
||||||
|
self::assertSame('1,500,000 ریال', $row['after']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کلینیک تازه نوبتی ندارد؛ اگر این حالت خطا بود، هرگز قانونی فعال نمیکرد. */
|
||||||
|
public function testEmptySampleSucceedsWithAWarning(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'حداقل ۳۰ دقیقه',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
|
||||||
|
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||||
|
self::assertSame(0, $body['data']['sample_size']);
|
||||||
|
self::assertSame('none', $body['data']['severity']);
|
||||||
|
self::assertSame('دادهای برای آزمایش نیست', $body['data']['warning']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** قانونی که همهٔ نمونه را رد میکند تقریباً همیشه اشتباه نوشته شده. */
|
||||||
|
public function testAPolicyThatRejectsEverythingIsFlaggedHigh(): void
|
||||||
|
{
|
||||||
|
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||||
|
$service = $this->service($section, 'بوتاکس');
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 3; $i++) {
|
||||||
|
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), $i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'selection',
|
||||||
|
'name' => 'توقف کامل خدمت',
|
||||||
|
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت متوقف است']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
|
||||||
|
self::assertSame(3, $body['data']['affected_count']);
|
||||||
|
self::assertSame('high', $body['data']['severity']);
|
||||||
|
self::assertSame('رد میشد', $body['data']['rows'][0]['after']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شرطی که هرگز برقرار نمیشود هم هشدار است، نه موفقیت. */
|
||||||
|
public function testAPolicyThatMatchesNothingIsFlaggedNone(): void
|
||||||
|
{
|
||||||
|
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||||
|
$service = $this->service($section, 'مشاوره');
|
||||||
|
|
||||||
|
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 2);
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'فقط برای سبد بزرگ',
|
||||||
|
'condition' => ['match' => 'all', 'conditions' => [
|
||||||
|
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 50],
|
||||||
|
]],
|
||||||
|
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
|
||||||
|
self::assertSame(1, $body['data']['sample_size']);
|
||||||
|
self::assertSame(0, $body['data']['affected_count']);
|
||||||
|
self::assertSame('none', $body['data']['severity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── دروازهٔ فعالسازی ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testActivateWithoutSimulationIsRejected(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'قانون آزمایشنشده',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
self::assertSame('ابتدا قانون را آزمایش کنید و نتیجه را ببینید', $body['errors'][0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimulationOfTheOldVersionDoesNotUnlockTheNewOne(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'قانون نسخهدار',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||||
|
self::assertSame(422, $this->responseCode(), 'آزمایش نسخهٔ ۱ نباید نسخهٔ ۲ را باز کند');
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
$activated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||||
|
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
self::assertTrue($activated['data']['active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimulationHistoryIsListedNewestFirst(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'قانون با تاریخچه',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}/simulations", $user);
|
||||||
|
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
self::assertCount(2, $body['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSampleSizeAboveTheCapIsRejected(): void
|
||||||
|
{
|
||||||
|
[$user] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($user, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'قانون با نمونهٔ بزرگ',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user, ['sample_size' => 500]);
|
||||||
|
|
||||||
|
// سقف بیصدا اعمال نمیشود: کاربری که ۵۰۰ خواسته باید بداند نگرفته.
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimulatingAnotherClinicsPolicyIsNotFound(): void
|
||||||
|
{
|
||||||
|
[$owner] = $this->clinicWithBranch();
|
||||||
|
[$other] = $this->clinicWithBranch();
|
||||||
|
|
||||||
|
$policy = $this->draft($owner, [
|
||||||
|
'category' => 'timing',
|
||||||
|
'name' => 'قانون کلینیک اول',
|
||||||
|
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $other);
|
||||||
|
|
||||||
|
self::assertSame(404, $this->responseCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user