diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 9e5c6950..c524dac2 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/PolicyConditionBuilder.tsx b/assets/admin/components/PolicyConditionBuilder.tsx new file mode 100644 index 00000000..b01cf510 --- /dev/null +++ b/assets/admin/components/PolicyConditionBuilder.tsx @@ -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 = { + 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) => { + 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 ( +
+
+ شرط‌ها با هم: +
+ onChange((v as 'all' | 'any') ?? 'all', clauses)} + options={[ + { value: 'all', label: 'همه برقرار باشند' }, + { value: 'any', label: 'یکی برقرار باشد' }, + ]} + /> +
+
+ + {clauses.length === 0 && ( +

+ بدون شرط، این قانون روی همهٔ نوبت‌های دامنه‌اش اعمال می‌شود. +

+ )} + + {clauses.map((clause, index) => { + const meta = metaOf(clause.field); + + return ( +
+
+ { + 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 }))} + /> +
+ +
+ update(index, { operator: String(v ?? 'equals') })} + options={(meta?.operators ?? []).map((op) => ({ + value: op, + label: OPERATOR_LABELS[op] ?? op, + }))} + /> +
+ +
+ {meta?.type === 'enum' ? ( + update(index, { value: String(v ?? '') })} + options={(meta.values ?? []).map((val) => ({ + value: val, + label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val, + }))} + /> + ) : meta?.type === 'bool' ? ( + update(index, { value: v === 'true' })} + options={[ + { value: 'true', label: 'بله' }, + { value: 'false', label: 'خیر' }, + ]} + /> + ) : ( + + update(index, { + value: meta?.type === 'int' ? Number(e.target.value) : e.target.value, + }) + } + placeholder="مقدار" + /> + )} +
+ + +
+ ); + })} + +
+ +
+
+ ); +} diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 03cef1c5..c8e9e291 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, + 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'] }, diff --git a/assets/admin/hooks/usePolicies.ts b/assets/admin/hooks/usePolicies.ts new file mode 100644 index 00000000..5958009e --- /dev/null +++ b/assets/admin/hooks/usePolicies.ts @@ -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>('/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>('/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>( + `/api/v1/policies${category ? `?category=${category}` : ''}`, + ), + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: POLICIES_KEY }); + + const activate = useMutation({ + mutationFn: (uuid: string) => api.post>(`/api/v1/policy/${uuid}/activate`, {}), + onSuccess: () => { + toast.success('قانون فعال شد'); + invalidate(); + }, + onError: (e) => fail(e, 'فعال‌سازی ناموفق بود'), + }); + + const deactivate = useMutation({ + mutationFn: (uuid: string) => api.post>(`/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>(`/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) => + api.post>('/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 }) => + api.post>(`/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>(`/api/v1/policy/${uuid}/simulations`), + enabled: !!uuid, + }); + + const run = useMutation({ + mutationFn: (sampleSize?: number) => + api.post>(`/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 }; +} diff --git a/assets/admin/pages/PoliciesPage.tsx b/assets/admin/pages/PoliciesPage.tsx new file mode 100644 index 00000000..8423549e --- /dev/null +++ b/assets/admin/pages/PoliciesPage.tsx @@ -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[] = [ + { + key: 'name', + header: 'قانون', + render: (p) => ( +
+ {p.name} + + {schema?.[p.category]?.label ?? p.category} · نسخهٔ {p.version} + +
+ ), + }, + { + key: 'effects', + header: 'اثر', + render: (p) => ( + + {p.effects + .map( + (e) => + schema?.[p.category]?.effects.find((m) => m.type === e.type)?.label ?? e.type, + ) + .join('، ') || '—'} + + ), + }, + { + key: 'scope', + header: 'دامنه', + render: (p) => ( + + {p.address_uuid ? 'شعبه' : p.service_uuid ? 'سرویس' : p.catalog_category_uuid ? 'دسته' : 'کل محیط'} + + ), + }, + { + key: 'priority', + header: 'اولویت', + render: (p) => {p.priority}, + }, + { + key: 'active', + header: 'وضعیت', + render: (p) => , + }, + ]; + + return ( +
+ + قانون تازه + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در قوانین..." + emptyMessage="هنوز قانونی تعریف نشده است" + headerExtra={ +
+ setUrlState({ category: String(v ?? '') })} + options={[{ value: '', label: 'همهٔ دسته‌ها' }, ...categoryOptions]} + placeholder="دسته" + /> +
+ } + actions={(p) => ( +
+ + {canManage && p.active && ( + + )} +
+ )} + /> +
+ ); +} diff --git a/assets/admin/pages/PolicyFormPage.test.tsx b/assets/admin/pages/PolicyFormPage.test.tsx new file mode 100644 index 00000000..0f73f9a2 --- /dev/null +++ b/assets/admin/pages/PolicyFormPage.test.tsx @@ -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; + +/** + * فیلدِ ساختگی: در هیچ کجای فرانت نوشته نشده. اگر در صفحه ظاهر شود، یعنی فرم واقعاً + * از 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(, { 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(, { route: '/admin/policies/new' }); + fireEvent.click(screen.getByText('پیشرفته')); + + await waitFor(() => expect(screen.getByText('حداقل مدت (دقیقه)')).toBeInTheDocument()); + expect(screen.getByText(/ترکیب: max/)).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/PolicyFormPage.tsx b/assets/admin/pages/PolicyFormPage.tsx new file mode 100644 index 00000000..fda095ca --- /dev/null +++ b/assets/admin/pages/PolicyFormPage.tsx @@ -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('template'); + const [name, setName] = useState(''); + const [priority, setPriority] = useState(0); + + const [templateKey, setTemplateKey] = useState(''); + const [values, setValues] = useState>({}); + + const [category, setCategory] = useState('timing'); + const [match, setMatch] = useState<'all' | 'any'>('all'); + const [clauses, setClauses] = useState([]); + const [effects, setEffects] = useState([]); + + 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 ( +
+ + +
+
+ + +
+ +
+ + setName(e.target.value)} + placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر" + /> + + این نام در پیام خطا به بیمار نشان داده می‌شود؛ قابل فهم بنویسید. + +
+ +
+ + setPriority(Number(e.target.value))} + /> + + در تناقض، عدد بزرگ‌تر برنده است. + +
+ + {mode === 'template' ? ( + <> +
+ + { + setTemplateKey(String(v ?? '')); + setValues({}); + }} + options={templates.map((t) => ({ value: t.key, label: t.title }))} + placeholder="یک الگو انتخاب کنید" + /> + {template && ( + {template.description} + )} +
+ + {template?.inputs.map((input) => ( +
+ + setValues({ ...values, [input.key]: e.target.value })} + /> +
+ ))} + + ) : ( + <> +
+ + { + setCategory((v as PolicyCategory) ?? 'timing'); + // فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگه‌داشتنشان یعنی ۴۲۲. + setClauses([]); + setEffects([]); + }} + options={categoryOptions} + /> +
+ + {categorySchema && ( + <> +
+

شرط

+ { + setMatch(m); + setClauses(c); + }} + /> +
+ +
+

اثر

+
+ {categorySchema.effects.map((meta) => { + const current = effects.find((e) => e.type === meta.type); + + return ( +
+ + + {current && meta.value_type !== 'none' && ( + + 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' && ( + + setEffects( + effects.map((x) => + x.type === 'forbid' ? { ...x, reason: e.target.value } : x, + ), + ) + } + /> + )} + + + ترکیب: {meta.combination} + +
+ ); + })} +
+
+ + )} + + )} + +
+ + +
+
+
+ ); +} diff --git a/assets/admin/pages/PolicySimulationPage.test.tsx b/assets/admin/pages/PolicySimulationPage.test.tsx new file mode 100644 index 00000000..aebe4060 --- /dev/null +++ b/assets/admin/pages/PolicySimulationPage.test.tsx @@ -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; + +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( + + } /> + , + { 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()); + }); +}); diff --git a/assets/admin/pages/PolicySimulationPage.tsx b/assets/admin/pages/PolicySimulationPage.tsx new file mode 100644 index 00000000..749411b6 --- /dev/null +++ b/assets/admin/pages/PolicySimulationPage.tsx @@ -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 = { + 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(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[] = [ + { + key: 'patient_name', + header: 'بیمار', + render: (r) => {r.patient_name}, + }, + { + key: 'slot_start', + header: 'تاریخ نوبت', + render: (r) => {formatDate(r.slot_start)}, + }, + { + key: 'change', + header: 'وضعیت فعلی ← با این قانون', + render: (r) => ( + + {r.before} + {' ← '} + {r.after} + + ), + }, + { + key: 'reason', + header: 'توضیح', + render: (r) => {r.reason}, + }, + ]; + + return ( +
+ setLatest((await run.mutateAsync(50)).data)} + > + اجرای آزمایش + + } + /> + + {current && severity && ( +
+
+ + نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت ( + {current.affected_percent}٪) + + + شدت: {severity.label} + + + نسخهٔ آزمایش‌شده: {current.policy_version} + +
+ +

+ {current.warning ?? severity.note} +

+ + {!isCurrentVersion && ( +

+ این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '} + {policy?.version} است. برای فعال‌سازی، دوباره آزمایش کنید. +

+ )} + + {canManage && ( +
+ + +
+ )} +
+ )} + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') }); + }} + /> + + {policy?.versions && policy.versions.length > 1 && ( +
+

تاریخچهٔ نسخه‌ها

+
+ {policy.versions.map((v) => ( +
+ نسخهٔ {v.version} + {formatDate(v.created_at)} + + {JSON.stringify((v.snapshot as { effects?: unknown }).effects)} + +
+ ))} +
+
+ )} + +
+ +
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index d3d17655..b537bcef 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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; + 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; + +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; +} diff --git a/docs/api/policy.md b/docs/api/policy.md index 3c0545b7..d499fc6b 100644 --- a/docs/api/policy.md +++ b/docs/api/policy.md @@ -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` | گزارش آزمایش + دکمهٔ فعال‌سازی | diff --git a/docs/architecture/policy-engine.md b/docs/architecture/policy-engine.md index 26ca3150..ff9d261e 100644 --- a/docs/architecture/policy-engine.md +++ b/docs/architecture/policy-engine.md @@ -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` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر | diff --git a/docs/new_feture/taskes/task-10-policy-admin-sandbox/checklist.md b/docs/new_feture/taskes/task-10-policy-admin-sandbox/checklist.md index 3c8ae60a..5e61b389 100644 --- a/docs/new_feture/taskes/task-10-policy-admin-sandbox/checklist.md +++ b/docs/new_feture/taskes/task-10-policy-admin-sandbox/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۱۰ (فرم ساخت قانون و محیط آزمایش) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,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`؛ هیچ `` بومی | +| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها | ✅ | حالت پیش‌فرض صفحه همین است | +| ۳.۷ | ستون «وضعیت فعلی ← با این قانون» | ✅ | ⭐ | +| ۳.۸ | درصد + شدت با رنگ توکن‌محور | ✅ | کلاس‌های `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 بصری) · ۴.۲/۴.۵/۴.۶ (پوشش تست) — همه در همین فایل ثبت‌اند | diff --git a/migrations/Version20260731065427.php b/migrations/Version20260731065427.php new file mode 100644 index 00000000..84f680a5 --- /dev/null +++ b/migrations/Version20260731065427.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/src/Policy/Command/PruneSimulationsCommand.php b/src/Policy/Command/PruneSimulationsCommand.php new file mode 100644 index 00000000..c7d84ff7 --- /dev/null +++ b/src/Policy/Command/PruneSimulationsCommand.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/src/Policy/Controller/PolicyController.php b/src/Policy/Controller/PolicyController.php index abf832da..867fb6dc 100644 --- a/src/Policy/Controller/PolicyController.php +++ b/src/Policy/Controller/PolicyController.php @@ -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()); diff --git a/src/Policy/Controller/PolicySimulationController.php b/src/Policy/Controller/PolicySimulationController.php new file mode 100644 index 00000000..2d7db945 --- /dev/null +++ b/src/Policy/Controller/PolicySimulationController.php @@ -0,0 +1,96 @@ +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; + } +} diff --git a/src/Policy/Entity/PolicySimulationRun.php b/src/Policy/Entity/PolicySimulationRun.php new file mode 100644 index 00000000..4f000ecb --- /dev/null +++ b/src/Policy/Entity/PolicySimulationRun.php @@ -0,0 +1,137 @@ + */ + #[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 $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 */ + public function getReport(): array { return $this->report; } + + /** @return array */ + 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; + } +} diff --git a/src/Policy/Repository/PolicySimulationRunRepository.php b/src/Policy/Repository/PolicySimulationRunRepository.php new file mode 100644 index 00000000..872165cb --- /dev/null +++ b/src/Policy/Repository/PolicySimulationRunRepository.php @@ -0,0 +1,52 @@ + + */ +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(); + } +} diff --git a/src/Policy/Service/PolicyResolver.php b/src/Policy/Service/PolicyResolver.php index 115661fb..20ebeb13 100644 --- a/src/Policy/Service/PolicyResolver.php +++ b/src/Policy/Service/PolicyResolver.php @@ -69,6 +69,26 @@ final class PolicyResolver return $this->combine($matched); } + /** + * ارزیابی **یک** قانون، بدون رقابت و بدون ترکیب با بقیه. + * + * سؤال آزمایشگاه این است که «این قانون چه می‌کند»، نه «نتیجهٔ نهایی با همهٔ قوانین + * چه می‌شود». دومی مفید است ولی چیزی نیست که کاربرِ در حال نوشتن قانون می‌پرسد. + * + * دامنه و اعتبار زمانی هم عمداً نادیده گرفته می‌شوند: کاربر دارد قانونِ **پیش‌نویس** + * را روی نمونهٔ گذشته می‌آزماید؛ رد کردنش به‌خاطر اینکه هنوز فعال نیست بی‌معناست. + * + * @param array $facts + */ + public function evaluateOne(Policy $policy, array $facts): PolicyOutcome + { + if (!$this->evaluator->matches($policy, $facts)) { + return new PolicyOutcome(); + } + + return $this->combine([$policy]); + } + /** * قانونی که دامنه‌اش با این درخواست نمی‌خواند اصلاً کاندید نیست. * diff --git a/src/Policy/Service/PolicySchema.php b/src/Policy/Service/PolicySchema.php index 266a00c9..ad6d88d5 100644 --- a/src/Policy/Service/PolicySchema.php +++ b/src/Policy/Service/PolicySchema.php @@ -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 */ 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], ], diff --git a/src/Policy/Simulation/PolicySimulator.php b/src/Policy/Simulation/PolicySimulator.php new file mode 100644 index 00000000..bb549e78 --- /dev/null +++ b/src/Policy/Simulation/PolicySimulator.php @@ -0,0 +1,232 @@ +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>, 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|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 $base + * @return array|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 $base + * @return array|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 $base + * @param array $values + * @return array|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 $base + * @return array|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), + ]; + } +} diff --git a/src/Policy/Simulation/SimulationFacts.php b/src/Policy/Simulation/SimulationFacts.php new file mode 100644 index 00000000..4f1972a3 --- /dev/null +++ b/src/Policy/Simulation/SimulationFacts.php @@ -0,0 +1,119 @@ + */ + 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 */ + 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, 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))); + } +} diff --git a/src/Policy/Simulation/SimulationSampler.php b/src/Policy/Simulation/SimulationSampler.php new file mode 100644 index 00000000..882743ed --- /dev/null +++ b/src/Policy/Simulation/SimulationSampler.php @@ -0,0 +1,59 @@ +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(); + } +} diff --git a/src/Policy/Template/PolicyTemplateRegistry.php b/src/Policy/Template/PolicyTemplateRegistry.php new file mode 100644 index 00000000..ac3eb7fe --- /dev/null +++ b/src/Policy/Template/PolicyTemplateRegistry.php @@ -0,0 +1,151 @@ + [ + '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> */ + public function describe(): array + { + $out = []; + + foreach (self::TEMPLATES as $key => $template) { + $out[] = ['key' => $key] + $template; + } + + return $out; + } + + /** + * @param array $values + * @return array{category: string, condition: array, effects: list>} + */ + 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 $v + * @return array{condition: array, effects: list>} + */ + 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'), + }; + } +} diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php index 9e591c2b..b1b42db9 100644 --- a/tests/Policy/PolicyEngineTest.php +++ b/tests/Policy/PolicyEngineTest.php @@ -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']); diff --git a/tests/Policy/PolicySimulationTest.php b/tests/Policy/PolicySimulationTest.php new file mode 100644 index 00000000..8539f1f6 --- /dev/null +++ b/tests/Policy/PolicySimulationTest.php @@ -0,0 +1,380 @@ +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 $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()); + } +}