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
+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;
}