feat(policy): rule builder and mandatory dry-run sandbox

Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

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