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 InventoryPage from './pages/InventoryPage';
|
||||
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 BranchRoomsPage from './pages/BranchRoomsPage';
|
||||
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/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
|
||||
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
|
||||
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
|
||||
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
|
||||
@@ -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 {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
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: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
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`
|
||||
|
||||
هر دسته `label` فارسی، فهرست `fields`، همهٔ `operators`، `field_meta` (فراداده per فیلد
|
||||
شامل **عملگرهای معنادار برای همان نوع**) و `effects` با برچسب و نوع مقدار دارد.
|
||||
|
||||
> فرم باید عملگرها را از `field_meta[].operators` بخواند نه از `operators` کلی؛ وگرنه
|
||||
> کاربر `patient_tags > 5` میسازد و `422` میگیرد بدون اینکه بفهمد چرا.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"selection": {
|
||||
"label": "انتخاب خدمات",
|
||||
"fields": ["item_count", "item_uuids", "catalog_category"],
|
||||
"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": {
|
||||
"fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
|
||||
@@ -124,7 +138,9 @@
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `category` | string | ✅ | یکی از شش دسته |
|
||||
| `template` | string | — | کلید یک الگو از `GET /policy-templates`؛ اگر بیاید، `category`/`condition`/`effects` از الگو ساخته میشوند |
|
||||
| `values` | object | — | مقادیر ورودیهای همان الگو |
|
||||
| `category` | string | ✅ (بدون `template`) | یکی از شش دسته |
|
||||
| `name` | string | ✅ | نام قابلفهم؛ در پیام ممنوعیت به کاربر نشان داده میشود |
|
||||
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
|
||||
| `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-hold` | `422` وقتی قانون `eligibility` بیمار را رد کند، پرچم لازم نیامده باشد، یا فاصلهٔ `spacing` رعایت نشده باشد |
|
||||
| `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 + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش میسازد |
|
||||
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینهاش** این است که اسلات نمایش داده میشود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
|
||||
| `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` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۱۰ (فرم ساخت قانون و محیط آزمایش)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ تمامشده با انحرافهای ثبتشده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -11,92 +11,95 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | شبیهسازی **هیچ ردیفی** نمینویسد (جز `policy_simulation_runs`) | ⏳ | ⭐⭐ با شمارش ردیف اثبات شود |
|
||||
| ۰.۳ | نوبتهای واقعی بیماران در شبیهسازی تغییر نکردند | ⏳ | |
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۰.۲ | شبیهسازی **هیچ ردیفی** نمینویسد (جز `policy_simulation_runs`) | ✅ | ⭐⭐ `testSimulationWritesNothingButItsOwnRun` پنج جدول را قبل/بعد میشمارد |
|
||||
| ۰.۳ | نوبتهای واقعی بیماران تغییر نکردند | ✅ | ارزیابی روی حقایق است، نه entity |
|
||||
|
||||
## ۱. بکاند — شبیهساز
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | ⏳ | |
|
||||
| ۱.۲ | سه لایهٔ تضمین: DTO · تراکنش با rollback در `finally` · تست شمارش | ⏳ | ⭐ |
|
||||
| ۱.۳ | `$this->em->clear()` بعد از rollback | ⏳ | ⭐ وگرنه entity کثیف در identity map |
|
||||
| ۱.۴ | `PolicySimulationRun` **بعد از** rollback و در تراکنش جدا ثبت میشود | ⏳ | |
|
||||
| ۱.۵ | `evaluateIsolated()` — فقط همان قانون، بدون `Resolver` و `Combiner` | ⏳ | |
|
||||
| ۱.۶ | فیلتر شعبه و سرویس از **خودِ شرط قانون** استخراج میشود | ⏳ | وگرنه «۰٪ تحت تأثیر» گمراهکننده |
|
||||
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | ⏳ | |
|
||||
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | ⏳ | |
|
||||
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** را میسنجد | ⏳ | ⭐ نسخهٔ ۱ اجازهٔ نسخهٔ ۲ نمیدهد |
|
||||
| ۱.۱۰ | محیط بدون نوبت → `simulate` خالی موفق، `activate` مجاز | ⏳ | ⭐ کلینیک جدید قفل نشود |
|
||||
| ۱.۱۱ | چهار سطح شدت با آستانههای مستند | ⏳ | |
|
||||
| ۱.۱۲ | دو endpoint | ⏳ | |
|
||||
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (policy, version) هرگز حذف نمیشود | ⏳ | `activate` به آن وابسته است |
|
||||
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | ✅ | بهعلاوهٔ `SimulationFacts` |
|
||||
| ۱.۲ | سه لایهٔ تضمین | ✅ | ⭐ حقایق (نه entity) · تراکنش با rollback در `finally` · تست شمارش |
|
||||
| ۱.۳ | `$this->em->clear()` بعد از rollback | ✅ | ⭐ قانون بعد از `clear` دوباره خوانده میشود |
|
||||
| ۱.۴ | ثبت نتیجه **بعد از** rollback و در تراکنش جدا | ✅ | |
|
||||
| ۱.۵ | ارزیابی جدا — فقط همان قانون | ⚠️ | `PolicyResolver::evaluateOne()` بهجای `evaluateIsolated()` روی شش موتور؛ شش موتوری وجود ندارد که متد بگیرد (انحراف تسک ۰۹) |
|
||||
| ۱.۶ | فیلتر شعبه/سرویس/دسته از دامنهٔ قانون | ⚠️ | از **دامنهٔ** قانون استخراج میشود، نه از داخل `condition`؛ شرطها فیلدِ id ندارند که به کوئری تبدیل شوند |
|
||||
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | ✅ | `testSampleSizeAboveTheCapIsRejected` |
|
||||
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | ✅ | شش الگو |
|
||||
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** | ✅ | ⭐ `testSimulationOfTheOldVersionDoesNotUnlockTheNewOne` |
|
||||
| ۱.۱۰ | محیط بدون نوبت → آزمایش خالی موفق، فعالسازی مجاز | ✅ | ⭐ `warning: دادهای برای آزمایش نیست` |
|
||||
| ۱.۱۱ | چهار سطح شدت با آستانههای مستند | ✅ | `PolicySimulationRun::severityFor()` — ۰٪ · ≤۲۰٪ · ≤۶۰٪ · >۶۰٪ |
|
||||
| ۱.۱۲ | دو endpoint | ✅ | سه تا: `policy-templates` · `simulate` · `simulations` |
|
||||
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (قانون، نسخه) حذف نمیشود | ✅ | `--days` و `--dry-run`؛ SQL با `MAX(id) GROUP BY policy_id, policy_version` |
|
||||
|
||||
## ۲. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `policy_simulation_runs` با جفت tenant | ⏳ | |
|
||||
| ۲.۲ | `report` سقف ۵۰ ردیف | ⏳ | |
|
||||
| ۲.۳ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | `policy_simulation_runs` با جفت tenant | ✅ | `Version20260731065427` + دو ایندکس |
|
||||
| ۲.۲ | `report` سقف ۵۰ ردیف | ✅ | از سقف نمونه میآید: بیش از ۵۰ نوبت اصلاً خوانده نمیشود |
|
||||
| ۲.۳ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||
|
||||
## ۳. UI
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | ⏳ | |
|
||||
| ۳.۲ | `PolicyConditionBuilder` **از `GET /policy-schema`** ساخته میشود | ⏳ | ⭐ هیچ فیلد hard-code |
|
||||
| ۳.۳ | عملگرها per فیلد **فیلتر** میشوند، نه همه | ⏳ | ⭐ وگرنه ۴۲۲ بیتوضیح |
|
||||
| ۳.۴ | نوع ورودی مقدار از `schema.fields[f].type` | ⏳ | |
|
||||
| ۳.۵ | همهٔ select ها `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
|
||||
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها (مسیر ۹۰٪ کاربران) | ⏳ | |
|
||||
| ۳.۷ | ستون «وضعیت فعلی → با این قانون» در گزارش | ⏳ | ⭐ تنها چیزی که کاربر غیرفنی میفهمد |
|
||||
| ۳.۸ | درصد تحت تأثیر + سطح شدت با رنگ توکنمحور | ⏳ | |
|
||||
| ۳.۹ | شدت `none` هم هشدار میدهد، با متن دوحالتی | ⏳ | |
|
||||
| ۳.۱۰ | شدت `high` → متن «مطمئنید؟» روی دکمهٔ فعالسازی | ⏳ | |
|
||||
| ۳.۱۱ | `ConfirmDialog` موجود برای فعالسازی | ⏳ | نه مودال دستساز |
|
||||
| ۳.۱۲ | `DataTable` برای لیست قوانین با فیلتر دسته/وضعیت در URL | ⏳ | |
|
||||
| ۳.۱۳ | `backTo`/`BackButton` روی هر سه صفحه | ⏳ | |
|
||||
| ۳.۱۴ | هیچ رنگ/شعاع hard-code — رنگهای شدت هم از توکن وضعیت | ⏳ | `--warning` `--danger` `--success` |
|
||||
| ۳.۱۵ | دارکمود و حالت فشرده | ⏳ | |
|
||||
| ۳.۱۶ | RTL و موبایل — جدول گزارش اسکرول افقی داخلی | ⏳ | |
|
||||
| ۳.۱۷ | تاریخها شمسی | ⏳ | |
|
||||
| ۳.۱۸ | همهٔ رشتهها فارسی | ⏳ | |
|
||||
| ۳.۱۹ | نمایش تاریخچهٔ نسخهها با diff | ⏳ | |
|
||||
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | ✅ | + ورودی «قوانین» در منوی تنظیمات |
|
||||
| ۳.۲ | `PolicyConditionBuilder` از `GET /policy-schema` | ✅ | ⭐ تست با فیلد ساختگی: بدون تغییر فرانت در UI ظاهر میشود |
|
||||
| ۳.۳ | عملگرها per فیلد فیلتر میشوند | ✅ | ⭐ `field_meta[].operators` — تست جداگانه |
|
||||
| ۳.۴ | نوع ورودی مقدار از schema | ✅ | int · enum · bool · list |
|
||||
| ۳.۵ | همهٔ selectها `SearchableSelect` | ✅ | هیچ `<select>` بومی |
|
||||
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها | ✅ | حالت پیشفرض صفحه همین است |
|
||||
| ۳.۷ | ستون «وضعیت فعلی ← با این قانون» | ✅ | ⭐ |
|
||||
| ۳.۸ | درصد + شدت با رنگ توکنمحور | ✅ | کلاسهای `badge green/amber/red` موجود |
|
||||
| ۳.۹ | شدت `none` هشدار میدهد | ✅ | «احتمالاً شرطش هرگز برقرار نمیشود» |
|
||||
| ۳.۱۰ | شدت `high` → تأیید دوم | ✅ | |
|
||||
| ۳.۱۱ | `ConfirmDialog` موجود | ✅ | نه `window.confirm` |
|
||||
| ۳.۱۲ | `DataTable` + فیلتر دسته در URL | ✅ | `useUrlState` |
|
||||
| ۳.۱۳ | `backTo` روی هر سه صفحه | ✅ | |
|
||||
| ۳.۱۴ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` |
|
||||
| ۳.۱۵ | دارکمود و حالت فشرده | ⚠️ | فقط توکنهای موجود استفاده شده؛ بازبینی چشمی انجام نشد |
|
||||
| ۳.۱۶ | RTL و موبایل — اسکرول افقی جدول | ✅ | `overflow-x: auto` دور جدول گزارش |
|
||||
| ۳.۱۷ | تاریخها شمسی | ✅ | `formatDate` |
|
||||
| ۳.۱۸ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۳.۱۹ | تاریخچهٔ نسخهها با diff | ⚠️ | فهرست نسخهها با اثرهای هر نسخه نمایش داده میشود؛ diff بصری واقعی نیست |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `PolicySimulatorTest` — شمارش ردیف قبل/بعد | ⏳ | ⭐⭐ |
|
||||
| ۴.۲ | `PolicySimulatorTest` — استثنا در `evaluateIsolated` → rollback + clear | ⏳ | |
|
||||
| ۴.۳ | `SimulationSamplerTest` — استخراج فیلتر، فقط confirmed/completed، سقف ۵۰ | ⏳ | |
|
||||
| ۴.۴ | `PolicyActivationGuardTest` — چهار حالت | ⏳ | ⭐ |
|
||||
| ۴.۵ | `PolicyTemplateTest` — هر الگو قانون معتبر تولید میکند (dataProvider) | ⏳ | ⭐ |
|
||||
| ۴.۶ | `SeverityTest` — چهار آستانه | ⏳ | |
|
||||
| ۴.۷ | `PolicyFormPage.test.tsx` — فیلد ساختگی از mock schema در UI ظاهر میشود | ⏳ | ⭐ |
|
||||
| ۴.۸ | `PolicyFormPage.test.tsx` — عملگر نامعتبر برای نوع نمایش داده نمیشود | ⏳ | |
|
||||
| ۴.۱ | شمارش ردیف قبل/بعد | ✅ | ⭐⭐ |
|
||||
| ۴.۲ | استثنا → rollback + clear | ⚠️ | `finally` تضمینش میکند ولی تست تزریق استثنا نوشته نشد |
|
||||
| ۴.۳ | نمونهگیری — فقط confirmed/completed، سقف | ✅ | سقف تست شد؛ فیلتر وضعیت غیرمستقیم (نوبتهای نمونه completed اند) |
|
||||
| ۴.۴ | دروازهٔ فعالسازی | ✅ | ⭐ بدون آزمایش، نسخهٔ قدیمی، نسخهٔ درست، محیط خالی |
|
||||
| ۴.۵ | هر الگو قانون معتبر میسازد | ⚠️ | یک الگو کامل تست شد (`vip_discount`) + ورودی ناقص؛ dataProvider ششتایی نوشته نشد |
|
||||
| ۴.۶ | چهار آستانهٔ شدت | ⚠️ | `high` و `none` تست شدند؛ `low`/`medium` نه |
|
||||
| ۴.۷ | فیلد ساختگی از mock schema در UI | ✅ | ⭐ `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/architecture/policy-engine.md` بخش «چرا آزمایش اجباری است» | ⏳ | ارجاع به ریسک دوم مستند |
|
||||
| ۵.۱ | `docs/api/policy.md` — `simulate`، `policy-templates`، شرط تازهٔ `activate` | ✅ | JSON واقعی |
|
||||
| ۵.۲ | `policy-engine.md` بخش «چرا آزمایش اجباری است» | ✅ | + سه لایهٔ تضمین و دو حالت مرزی |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | تستهای tenant سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | این تسک قرارداد عمومی عوض نمیکند |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ✅ | ۷ مورد ⚠️ همه با دلیل |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۲۵۰ تست |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline |
|
||||
| ۶.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۲۸ تست؛ **`yarn test` داخل ddev اجرا نمیشود** (باینری esbuild برای darwin نصب شده) — روی هاست اجرا شد |
|
||||
| ۶.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ✅ | جز ۳.۱۵ و ۳.۱۹ |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ✅ | این تسک هیچ قرارداد عمومیای عوض نکرد؛ همهٔ اندپوینتها پنلمحورند |
|
||||
| ۶.۱۰ | 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\PolicyVersionLog;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use App\Policy\Template\PolicyTemplateRegistry;
|
||||
use App\Policy\Service\ConditionEvaluator;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -31,6 +33,8 @@ class PolicyController extends BaseController
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicyVersionLogRepository $versions,
|
||||
private readonly PolicySimulationRunRepository $simulations,
|
||||
private readonly PolicyTemplateRegistry $templates,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly ServiceItemRepository $items,
|
||||
@@ -74,7 +78,17 @@ class PolicyController extends BaseController
|
||||
{
|
||||
$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');
|
||||
}
|
||||
|
||||
@@ -134,10 +148,28 @@ class PolicyController extends BaseController
|
||||
return $this->success($policy->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* فعالسازی — فقط بعد از یک اجرای آزمایشیِ **همین نسخه**.
|
||||
*
|
||||
* آزمایش نسخهٔ ۱ اجازهٔ فعالسازی نسخهٔ ۲ را نمیدهد: کاربر متن قانون را عوض کرده و
|
||||
* گزارشی که دیده دیگر توصیف این قانون نیست.
|
||||
*/
|
||||
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* ارزیابی **یک** قانون، بدون رقابت و بدون ترکیب با بقیه.
|
||||
*
|
||||
* سؤال آزمایشگاه این است که «این قانون چه میکند»، نه «نتیجهٔ نهایی با همهٔ قوانین
|
||||
* چه میشود». دومی مفید است ولی چیزی نیست که کاربرِ در حال نوشتن قانون میپرسد.
|
||||
*
|
||||
* دامنه و اعتبار زمانی هم عمداً نادیده گرفته میشوند: کاربر دارد قانونِ **پیشنویس**
|
||||
* را روی نمونهٔ گذشته میآزماید؛ رد کردنش بهخاطر اینکه هنوز فعال نیست بیمعناست.
|
||||
*
|
||||
* @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',
|
||||
];
|
||||
|
||||
/**
|
||||
* فرادادهٔ هر فیلد: برچسب فارسی، نوع ورودی، و عملگرهایی که **برای همان نوع** معنا
|
||||
* دارند.
|
||||
*
|
||||
* فیلتر شدن عملگرها اختیاری نیست: اگر فرم همهٔ شش عملگر را نشان بدهد، کاربر
|
||||
* `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> */
|
||||
public function describe(): array
|
||||
{
|
||||
@@ -83,10 +133,18 @@ final class PolicySchema
|
||||
|
||||
foreach (Policy::CATEGORIES as $category) {
|
||||
$out[$category] = [
|
||||
'fields' => self::FIELDS[$category],
|
||||
'operators' => self::OPERATORS,
|
||||
'effects' => array_map(
|
||||
static fn (string $effect): array => [
|
||||
'label' => self::CATEGORY_LABELS[$category],
|
||||
'fields' => self::FIELDS[$category],
|
||||
'operators' => self::OPERATORS,
|
||||
'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,
|
||||
'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'];
|
||||
}
|
||||
|
||||
// فعالسازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** میخواهد.
|
||||
$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);
|
||||
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']);
|
||||
|
||||
// فرم باید عملگرها را 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
|
||||
@@ -390,6 +403,8 @@ class PolicyEngineTest extends ApiTestCase
|
||||
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(2, $updated['data']['version']);
|
||||
|
||||
// نسخهٔ تازه فعال میماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود.
|
||||
|
||||
$second = $this->quote($user, $service, $address);
|
||||
self::assertSame(200_000, $second['data']['discount_rials']);
|
||||
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