feat: document rule DSL and result calc flows
This commit is contained in:
parent
134040fcb4
commit
41ebbb7b33
BIN
.cocoindex_code/cocoindex.db/mdb/data.mdb
Normal file
BIN
.cocoindex_code/cocoindex.db/mdb/data.mdb
Normal file
Binary file not shown.
BIN
.cocoindex_code/cocoindex.db/mdb/lock.mdb
Normal file
BIN
.cocoindex_code/cocoindex.db/mdb/lock.mdb
Normal file
Binary file not shown.
41
.cocoindex_code/settings.yml
Normal file
41
.cocoindex_code/settings.yml
Normal file
@ -0,0 +1,41 @@
|
||||
exclude_patterns:
|
||||
- '**/.*'
|
||||
- '**/__pycache__'
|
||||
- '**/node_modules'
|
||||
- '**/target'
|
||||
- '**/build/assets'
|
||||
- '**/dist'
|
||||
- '**/vendor/*.*/*'
|
||||
- '**/vendor/*'
|
||||
- '**/.cocoindex_code'
|
||||
include_patterns:
|
||||
- '**/*.py'
|
||||
- '**/*.pyi'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.mjs'
|
||||
- '**/*.cjs'
|
||||
- '**/*.rs'
|
||||
- '**/*.go'
|
||||
- '**/*.java'
|
||||
- '**/*.c'
|
||||
- '**/*.h'
|
||||
- '**/*.cpp'
|
||||
- '**/*.hpp'
|
||||
- '**/*.cc'
|
||||
- '**/*.cxx'
|
||||
- '**/*.hxx'
|
||||
- '**/*.hh'
|
||||
- '**/*.cs'
|
||||
- '**/*.sql'
|
||||
- '**/*.sh'
|
||||
- '**/*.bash'
|
||||
- '**/*.zsh'
|
||||
- '**/*.md'
|
||||
- '**/*.mdx'
|
||||
- '**/*.txt'
|
||||
- '**/*.rst'
|
||||
- '**/*.php'
|
||||
- '**/*.lua'
|
||||
BIN
.cocoindex_code/target_sqlite.db
Normal file
BIN
.cocoindex_code/target_sqlite.db
Normal file
Binary file not shown.
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@ -1,29 +0,0 @@
|
||||
# CLQMS Frontend Project Overview
|
||||
|
||||
- Purpose: Frontend for Clinical Laboratory Quality Management System (CLQMS), handling authenticated lab quality workflows.
|
||||
- App type: SvelteKit SPA/static frontend that talks to backend API.
|
||||
- Backend dependency: API expected at `http://localhost:8000` in development; `/api` is proxied in Vite.
|
||||
- Auth model: JWT-based auth with automatic redirect to `/login` on 401.
|
||||
- Main docs: `README.md`, `AGENTS.md`, `DEPLOY.md`.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- SvelteKit `^2.50.2`
|
||||
- Svelte `^5.49.2` with runes (`$props`, `$state`, `$derived`, `$effect`, `$bindable`)
|
||||
- Vite `^7.3.1`
|
||||
- Tailwind CSS 4 + DaisyUI 5
|
||||
- Lucide Svelte icons
|
||||
- Package manager: pnpm
|
||||
- Module system: ES Modules (`"type": "module"`)
|
||||
|
||||
## Rough structure
|
||||
|
||||
- `src/lib/api/` API client and feature endpoints
|
||||
- `src/lib/stores/` shared stores (auth, config, valuesets)
|
||||
- `src/lib/components/` reusable UI (Modal, DataTable, Sidebar)
|
||||
- `src/lib/utils/` helpers and toast utilities
|
||||
- `src/lib/types/` TS type definitions
|
||||
- `src/routes/(app)/` authenticated pages (`dashboard`, `patients`, `master-data`)
|
||||
- `src/routes/login/` public login route
|
||||
- `static/` static assets
|
||||
- `build/` production output
|
||||
@ -1,42 +0,0 @@
|
||||
# Style and Conventions
|
||||
|
||||
Primary source: `AGENTS.md`.
|
||||
|
||||
## JavaScript/TypeScript style
|
||||
|
||||
- Use ES modules (`import`/`export`).
|
||||
- Semicolons required.
|
||||
- Single quotes for strings.
|
||||
- 2-space indentation.
|
||||
- Trailing commas in multi-line arrays/objects.
|
||||
- Document exported functions with JSDoc including `@param` and `@returns`.
|
||||
|
||||
## Import ordering
|
||||
|
||||
1. Svelte / `$app/*`
|
||||
2. `$lib/*`
|
||||
3. External libraries (e.g., `lucide-svelte`)
|
||||
4. Relative imports (minimize, prefer `$lib`)
|
||||
|
||||
## Naming
|
||||
|
||||
- Components: PascalCase (`LoginForm.svelte`)
|
||||
- Route/files: lowercase with hyphens
|
||||
- Variables/stores: camelCase
|
||||
- Constants: UPPER_SNAKE_CASE
|
||||
- Event handlers: `handle...`
|
||||
- Form state fields: `formLoading`, `formError`, etc.
|
||||
|
||||
## Svelte 5 patterns
|
||||
|
||||
- Follow component script order: imports -> props -> state -> derived -> effects -> handlers.
|
||||
- Prefer DaisyUI component classes (`btn`, `input`, `card`, etc.).
|
||||
- For icon inputs, use DaisyUI label+input flex pattern (not absolute-positioned icons).
|
||||
- Access browser-only APIs behind `$app/environment` `browser` checks.
|
||||
|
||||
## API/store patterns
|
||||
|
||||
- Use shared API helpers from `$lib/api/client.js` (`get/post/put/patch/del`).
|
||||
- Build query strings using `URLSearchParams`.
|
||||
- Use try/catch with toast error/success utilities.
|
||||
- LocalStorage keys should be descriptive (e.g., `clqms_username`, `auth_token`).
|
||||
@ -1,32 +0,0 @@
|
||||
# Suggested Commands (Windows project shell)
|
||||
|
||||
## Core project commands (pnpm)
|
||||
|
||||
- `pnpm install` - install dependencies.
|
||||
- `pnpm run dev` - run local dev server.
|
||||
- `pnpm run build` - create production build (`build/`).
|
||||
- `pnpm run preview` - preview production build.
|
||||
- `pnpm run prepare` - run SvelteKit sync.
|
||||
|
||||
## Testing/linting/formatting status
|
||||
|
||||
- No lint command configured yet.
|
||||
- No format command configured yet.
|
||||
- No test command configured yet.
|
||||
- Notes in `AGENTS.md` mention future options like Vitest/Playwright, but not currently wired in scripts.
|
||||
|
||||
## Useful Windows shell commands
|
||||
|
||||
- `dir` - list files (cmd).
|
||||
- `Get-ChildItem` or `ls` - list files (PowerShell).
|
||||
- `cd <path>` - change directory.
|
||||
- `git status` - working tree status.
|
||||
- `git diff` - inspect changes.
|
||||
- `git log --oneline -n 10` - recent commits.
|
||||
- `findstr /S /N /I "text" *` - basic content search in cmd.
|
||||
- `Select-String -Path .\* -Pattern "text" -Recurse` - content search in PowerShell.
|
||||
|
||||
## Environment notes
|
||||
|
||||
- Node.js 18+ required.
|
||||
- Backend API should be running at `http://localhost:8000` for dev proxying.
|
||||
@ -1,15 +0,0 @@
|
||||
# Task Completion Checklist
|
||||
|
||||
Given current project setup:
|
||||
|
||||
1. Run relevant build verification:
|
||||
- `pnpm run build`
|
||||
2. If runtime behavior changed, also sanity check with:
|
||||
- `pnpm run dev` (manual smoke test)
|
||||
- optionally `pnpm run preview` for production-like validation
|
||||
3. Since lint/format/test scripts are not configured, mention this explicitly in handoff.
|
||||
4. Ensure code follows `AGENTS.md` conventions:
|
||||
- semicolons, single quotes, import order, Svelte 5 rune patterns, naming.
|
||||
5. For API/auth/localStorage changes:
|
||||
- verify browser-only access guards (`browser`) and auth redirect behavior are preserved.
|
||||
6. In final handoff, include changed file paths and any manual verification steps performed.
|
||||
@ -1,135 +0,0 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "clqms01-fe"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,166 +0,0 @@
|
||||
# Backend API Specification: Calculation Engine
|
||||
|
||||
## Overview
|
||||
Endpoint to evaluate calculated test formulas and return computed values with proper rounding and error handling.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/calculate/evaluate
|
||||
```
|
||||
|
||||
## Request Body
|
||||
|
||||
```typescript
|
||||
{
|
||||
// The formula expression using test codes as variables
|
||||
// Example: "CHOL - HDL - (TG/5)"
|
||||
formula: string;
|
||||
|
||||
// Map of test codes to their current numeric values
|
||||
// Example: { "CHOL": 180, "HDL": 45, "TG": 150 }
|
||||
values: Record<string, number>;
|
||||
|
||||
// Decimal precision for rounding (0-6)
|
||||
// Default: 2
|
||||
decimal?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Response Body
|
||||
|
||||
### Success (200)
|
||||
|
||||
```typescript
|
||||
{
|
||||
status: "success";
|
||||
data: {
|
||||
// The computed result value
|
||||
result: number;
|
||||
|
||||
// The result rounded to specified decimal places
|
||||
resultRounded: number;
|
||||
|
||||
// Formula that was evaluated (for verification)
|
||||
evaluatedFormula: string;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error (400/422)
|
||||
|
||||
```typescript
|
||||
{
|
||||
status: "error";
|
||||
message: string;
|
||||
error: {
|
||||
// Error type for frontend handling
|
||||
type: "MISSING_VALUE" | "INVALID_EXPRESSION" | "DIVISION_BY_ZERO" | "SYNTAX_ERROR";
|
||||
|
||||
// Missing variable names if applicable
|
||||
missingVars?: string[];
|
||||
|
||||
// Position of syntax error if applicable
|
||||
position?: number;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Formula Syntax
|
||||
|
||||
### Supported Operators
|
||||
- Arithmetic: `+`, `-`, `*`, `/`, `^` (power)
|
||||
- Parentheses: `(` `)` for grouping
|
||||
- Functions: `abs()`, `round()`, `floor()`, `ceil()`, `min()`, `max()`, `sqrt()`
|
||||
|
||||
### Variable Names
|
||||
- Test codes are used as variable names directly
|
||||
- Case-sensitive (CHOL ≠ chol)
|
||||
- Must match exactly (word boundaries)
|
||||
|
||||
### Examples
|
||||
|
||||
**Simple subtraction:**
|
||||
```
|
||||
Formula: "CHOL - HDL"
|
||||
Values: { "CHOL": 180, "HDL": 45 }
|
||||
Result: 135
|
||||
```
|
||||
|
||||
**Complex with division:**
|
||||
```
|
||||
Formula: "CHOL - HDL - (TG/5)"
|
||||
Values: { "CHOL": 180, "HDL": 45, "TG": 150 }
|
||||
Result: 105
|
||||
```
|
||||
|
||||
**With decimal rounding:**
|
||||
```
|
||||
Formula: "(HGB * MCV) / 100"
|
||||
Values: { "HGB": 14.2, "MCV": 87.5 }
|
||||
Decimal: 2
|
||||
Result: 12.43
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. **Missing Values**: If any variable in formula is not provided in values, return MISSING_VALUE error
|
||||
2. **Division by Zero**: Return DIVISION_BY_ZERO error if encountered
|
||||
3. **Invalid Syntax**: Return SYNTAX_ERROR with position if formula cannot be parsed
|
||||
4. **Non-numeric Values**: Return MISSING_VALUE if any value is not a valid number
|
||||
|
||||
## Batch Endpoint (Optional)
|
||||
|
||||
For efficiency when recalculating multiple CALC tests:
|
||||
|
||||
```
|
||||
POST /api/calculate/evaluate-batch
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
{
|
||||
calculations: [
|
||||
{
|
||||
testSiteId: number;
|
||||
formula: string;
|
||||
values: Record<string, number>;
|
||||
decimal?: number;
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
status: "success";
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
testSiteId: number;
|
||||
result: number;
|
||||
resultRounded: number;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The frontend will:
|
||||
1. Build dependency graph from test definitions
|
||||
2. Detect when member test values change
|
||||
3. Call this API to compute dependent CALC tests
|
||||
4. Update UI with computed values
|
||||
5. Mark CALC tests as `changedByAutoCalc` for save tracking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. Never use `eval()` or similar unsafe evaluation
|
||||
2. Use a proper expression parser (mathjs, muparser, or custom parser)
|
||||
3. Sanitize/validate formula input before parsing
|
||||
4. Limit computation time to prevent DoS
|
||||
@ -1,4 +1,4 @@
|
||||
/api/rules:
|
||||
/api/rule:
|
||||
get:
|
||||
tags: [Rules]
|
||||
summary: List rules
|
||||
@ -100,7 +100,7 @@
|
||||
'201':
|
||||
description: Rule created
|
||||
|
||||
/api/rules/{id}:
|
||||
/api/rule/{id}:
|
||||
get:
|
||||
tags: [Rules]
|
||||
summary: Get rule (with actions)
|
||||
@ -178,7 +178,7 @@
|
||||
'404':
|
||||
description: Rule not found
|
||||
|
||||
/api/rules/validate:
|
||||
/api/rule/validate:
|
||||
post:
|
||||
tags: [Rules]
|
||||
summary: Validate/evaluate an expression
|
||||
@ -201,7 +201,7 @@
|
||||
'200':
|
||||
description: Validation result
|
||||
|
||||
/api/rules/{id}/actions:
|
||||
/api/rule/{id}/actions:
|
||||
get:
|
||||
tags: [Rules]
|
||||
summary: List actions for a rule
|
||||
@ -261,7 +261,7 @@
|
||||
'201':
|
||||
description: Action created
|
||||
|
||||
/api/rules/{id}/actions/{actionId}:
|
||||
/api/rule/{id}/actions/{actionId}:
|
||||
patch:
|
||||
tags: [Rules]
|
||||
summary: Update action
|
||||
|
||||
337
docs/test-calc-engine.md
Normal file
337
docs/test-calc-engine.md
Normal file
@ -0,0 +1,337 @@
|
||||
# Calculator Service Operators Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The `CalculatorService` (`app/Services/CalculatorService.php`) evaluates formulas with Symfony's `ExpressionLanguage`. This document lists the operators, functions, and constants that are available in the current implementation.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints live under `/api` and accept JSON. Responses use the standard `{ status, message, data }` envelope unless stated otherwise.
|
||||
|
||||
### Calculate By Test Site
|
||||
|
||||
Uses the `testdefcal` definition for a test site. The incoming body supplies the variables required by the formula.
|
||||
|
||||
```http
|
||||
POST /api/calc/testsite/123
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"result": 85,
|
||||
"gender": "female",
|
||||
"age": 30
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"result": 92.4,
|
||||
"testSiteID": 123,
|
||||
"formula": "{result} * {factor} + {age}",
|
||||
"variables": {
|
||||
"result": 85,
|
||||
"gender": "female",
|
||||
"age": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Calculate By Code Or Name
|
||||
|
||||
Evaluates a configured calculation by `TestSiteCode` or `TestSiteName`. Returns a compact map with a single key/value or `{}` on failure.
|
||||
|
||||
```http
|
||||
POST /api/calc/testcode/GLU
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"result": 110,
|
||||
"factor": 1.1
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"GLU": 121
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Operators
|
||||
|
||||
### Arithmetic Operators
|
||||
|
||||
| Operator | Description | Example | Result |
|
||||
|----------|-------------|---------|--------|
|
||||
| `+` | Addition | `5 + 3` | `8` |
|
||||
| `-` | Subtraction | `10 - 4` | `6` |
|
||||
| `*` | Multiplication | `6 * 7` | `42` |
|
||||
| `/` | Division | `20 / 4` | `5` |
|
||||
| `%` | Modulo | `20 % 6` | `2` |
|
||||
| `**` | Exponentiation (power) | `2 ** 3` | `8` |
|
||||
|
||||
### Comparison Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `==` | Equal | `{result} == 10` |
|
||||
| `!=` | Not equal | `{result} != 10` |
|
||||
| `<` | Less than | `{result} < 10` |
|
||||
| `<=` | Less than or equal | `{result} <= 10` |
|
||||
| `>` | Greater than | `{result} > 10` |
|
||||
| `>=` | Greater than or equal | `{result} >= 10` |
|
||||
|
||||
### Logical Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `and` / `&&` | Logical AND | `{result} > 0 and {factor} > 0` |
|
||||
| `or` / `||` | Logical OR | `{gender} == 1 or {gender} == 2` |
|
||||
| `!` / `not` | Logical NOT | `not ({result} > 0)` |
|
||||
|
||||
### Conditional Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `?:` | Ternary | `{result} > 10 ? {result} : 10` |
|
||||
| `??` | Null coalescing | `{result} ?? 0` |
|
||||
|
||||
### Parentheses
|
||||
|
||||
Use parentheses to control operation precedence:
|
||||
|
||||
```
|
||||
(2 + 3) * 4 // Result: 20
|
||||
2 + 3 * 4 // Result: 14
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `^` is bitwise XOR (not exponentiation). Use `**` for powers.
|
||||
- Variables must be numeric after normalization (gender is mapped to 0/1/2).
|
||||
|
||||
---
|
||||
|
||||
## Functions
|
||||
|
||||
Only the default ExpressionLanguage functions are available:
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `min(a, b, ...)` | Minimum value | `min({result}, 10)` |
|
||||
| `max(a, b, ...)` | Maximum value | `max({result}, 10)` |
|
||||
| `constant(name)` | PHP constant by name | `constant("PHP_INT_MAX")` |
|
||||
| `enum(name)` | PHP enum case by name | `enum("App\\Enum\\Status::Active")` |
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
ExpressionLanguage recognizes boolean and null literals:
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `true` | `true` | Boolean true |
|
||||
| `false` | `false` | Boolean false |
|
||||
| `null` | `null` | Null value |
|
||||
|
||||
---
|
||||
|
||||
## Variables in CalculatorService
|
||||
|
||||
When using `calculateFromDefinition()`, the following variables are automatically available:
|
||||
|
||||
| Variable | Description | Type |
|
||||
|----------|-------------|------|
|
||||
| `{result}` | The test result value | Float |
|
||||
| `{factor}` | Calculation factor (default: 1) | Float |
|
||||
| `{gender}` | Gender value (0=Unknown, 1=Female, 2=Male) | Integer |
|
||||
| `{age}` | Patient age | Float |
|
||||
| `{ref_low}` | Reference range low value | Float |
|
||||
| `{ref_high}` | Reference range high value | Float |
|
||||
|
||||
### Gender Mapping
|
||||
|
||||
The `gender` variable accepts the following values:
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `0` | Unknown |
|
||||
| `1` | Female |
|
||||
| `2` | Male |
|
||||
|
||||
Or use string values: `'unknown'`, `'female'`, `'male'`
|
||||
|
||||
---
|
||||
|
||||
## Implicit Multiplication
|
||||
|
||||
Implicit multiplication is not supported. Always use `*` between values:
|
||||
|
||||
| Expression | Use Instead |
|
||||
|------------|-------------|
|
||||
| `2x` | `2 * x` |
|
||||
| `{result}{factor}` | `{result} * {factor}` |
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Calculation
|
||||
|
||||
```php
|
||||
use App\Services\CalculatorService;
|
||||
|
||||
$calculator = new CalculatorService();
|
||||
|
||||
// Simple arithmetic
|
||||
$result = $calculator->calculate("5 + 3 * 2");
|
||||
// Result: 11
|
||||
|
||||
// Using min/max
|
||||
$result = $calculator->calculate("max({result}, 10)", ['result' => 7]);
|
||||
// Result: 10
|
||||
```
|
||||
|
||||
### With Variables
|
||||
|
||||
```php
|
||||
$formula = "{result} * {factor} + 10";
|
||||
$variables = [
|
||||
'result' => 5.2,
|
||||
'factor' => 2
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 20.4
|
||||
```
|
||||
|
||||
### BMI Calculation
|
||||
|
||||
```php
|
||||
$formula = "{weight} / ({height} ** 2)";
|
||||
$variables = [
|
||||
'weight' => 70, // kg
|
||||
'height' => 1.75 // meters
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 22.86
|
||||
```
|
||||
|
||||
### Gender-Based Calculation
|
||||
|
||||
```php
|
||||
// Apply different multipliers based on gender
|
||||
$formula = "{result} * (1 + 0.1 * {gender})";
|
||||
$variables = [
|
||||
'result' => 100,
|
||||
'gender' => 1 // Female = 1
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 110
|
||||
```
|
||||
|
||||
### Complex Formula
|
||||
|
||||
```php
|
||||
// Pythagorean theorem
|
||||
$formula = "(({a} ** 2 + {b} ** 2) ** 0.5)";
|
||||
$variables = [
|
||||
'a' => 3,
|
||||
'b' => 4
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 5
|
||||
```
|
||||
|
||||
### Using calculateFromDefinition
|
||||
|
||||
```php
|
||||
$calcDef = [
|
||||
'FormulaCode' => '{result} * {factor} + {gender}',
|
||||
'Factor' => 2
|
||||
];
|
||||
|
||||
$testValues = [
|
||||
'result' => 10,
|
||||
'gender' => 1 // Female
|
||||
];
|
||||
|
||||
$result = $calculator->calculateFromDefinition($calcDef, $testValues);
|
||||
// Result: 21 (10 * 2 + 1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Formula Validation
|
||||
|
||||
Validate formulas before storing them:
|
||||
|
||||
```php
|
||||
$validation = $calculator->validate("{result} / {factor}");
|
||||
// Returns: ['valid' => true, 'error' => null]
|
||||
|
||||
$validation = $calculator->validate("{result} /");
|
||||
// Returns: ['valid' => false, 'error' => 'Error message']
|
||||
```
|
||||
|
||||
### Extract Variables
|
||||
|
||||
Get a list of variables used in a formula:
|
||||
|
||||
```php
|
||||
$variables = $calculator->extractVariables("{result} * {factor} + {age}");
|
||||
// Returns: ['result', 'factor', 'age']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service throws exceptions for invalid formulas or missing variables:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $calculator->calculate("{result} / 0");
|
||||
} catch (\Exception $e) {
|
||||
// Handle division by zero or other errors
|
||||
log_message('error', $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
Common errors:
|
||||
|
||||
- **Invalid formula syntax**: Malformed expressions
|
||||
- **Missing variable**: Variable placeholder not provided in data array
|
||||
- **Non-numeric value**: Variables must be numeric
|
||||
- **Division by zero**: Mathematical error
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always validate formulas** before storing in database
|
||||
2. **Use placeholder syntax** `{variable_name}` for clarity
|
||||
3. **Handle exceptions** in production code
|
||||
4. **Test edge cases** like zero values and boundary conditions
|
||||
5. **Document formulas** with comments in your code
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Symfony ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html)
|
||||
- `app/Services/CalculatorService.php`
|
||||
421
docs/test-rule-engine.md
Normal file
421
docs/test-rule-engine.md
Normal file
@ -0,0 +1,421 @@
|
||||
# Test Rule Engine Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The CLQMS Rule Engine evaluates business rules that inspect orders, patients, and tests, then executes actions when the compiled condition matches.
|
||||
|
||||
Rules are authored using a domain specific language stored in `ruledef.ConditionExpr`. Before the platform executes any rule, the DSL must be compiled into JSON and stored in `ConditionExprCompiled`, and each rule must be linked to the tests it should influence via `testrule`.
|
||||
|
||||
### Execution Flow
|
||||
|
||||
1. Write or edit the DSL in `ConditionExpr`.
|
||||
2. POST the expression to `POST /api/rule/compile` to validate syntax and produce compiled JSON.
|
||||
3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`.
|
||||
4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests).
|
||||
5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions.
|
||||
|
||||
> **Note:** The rule engine currently fires only for `test_created` and `result_updated`. Other event codes can exist in the database but are not triggered by the application unless additional `RuleEngineService::run(...)` calls are added.
|
||||
|
||||
## Event Triggers
|
||||
|
||||
| Event Code | Status | Trigger Point |
|
||||
|------------|--------|----------------|
|
||||
| `test_created` | Active | Fired after a new test row is persisted; the handler calls `RuleEngineService::run('test_created', ...)` to evaluate test-scoped rules |
|
||||
| `result_updated` | Active | Fired whenever a test result is saved or updated so result-dependent rules run immediately |
|
||||
|
||||
Other event codes remain in the database for future workflows, but only `test_created` and `result_updated` are executed by the current application flow.
|
||||
|
||||
## Rule Structure
|
||||
|
||||
```
|
||||
Rule
|
||||
├── Event Trigger (when to run)
|
||||
├── Conditions (when to match)
|
||||
└── Actions (what to do)
|
||||
```
|
||||
|
||||
The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rule/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime.
|
||||
|
||||
## Syntax Guide
|
||||
|
||||
### Basic Format
|
||||
|
||||
```
|
||||
if(condition; then-action; else-action)
|
||||
```
|
||||
|
||||
### Logical Operators
|
||||
|
||||
- Use `&&` for AND (all sub-conditions must match).
|
||||
- Use `||` for OR (any matching branch satisfies the rule).
|
||||
- Surround mixed logic with parentheses for clarity and precedence.
|
||||
|
||||
### Multi-Action Syntax
|
||||
|
||||
Actions within any branch are separated by `:` and evaluated in order. Every `then` and `else` branch must end with an action; use `nothing` when no further work is required.
|
||||
|
||||
```
|
||||
if(sex('M'); result_set(0.5):test_insert('HBA1C'); nothing)
|
||||
```
|
||||
|
||||
### Multiple Rules
|
||||
|
||||
Create each rule as its own `ruledef` row; do not chain expressions with commas. The `testrule` table manages rule-to-test mappings, so multiple rules can attach to the same test. Example:
|
||||
|
||||
1. Insert `RULE_MALE_RESULT` and `RULE_SENIOR_COMMENT` in `ruledef`.
|
||||
2. Add two `testrule` rows linking each rule to the appropriate `TestSiteID`.
|
||||
|
||||
Each rule compiles and runs independently when its trigger fires and the test is linked.
|
||||
|
||||
## Available Functions
|
||||
|
||||
### Conditions
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `sex('M'|'F')` | Match patient sex | `sex('M')` |
|
||||
| `priority('R'|'S'|'U')` | Match order priority | `priority('S')` |
|
||||
| `age > 18` | Numeric age comparisons (`>`, `<`, `>=`, `<=`) | `age >= 18 && age <= 65` |
|
||||
| `requested('CODE')` | Check whether the order already requested a test (queries `patres`) | `requested('GLU')` |
|
||||
|
||||
### Logical Operators
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `&&` | AND (all truthy) | `sex('M') && age > 40` |
|
||||
| `||` | OR (any truthy) | `sex('M') || age > 65` |
|
||||
| `()` | Group expressions | `(sex('M') && age > 40) || priority('S')` |
|
||||
|
||||
## Actions
|
||||
|
||||
| Action | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `result_set(value)` | (deprecated) Write to `patres.Result` for the current context test | `result_set(0.5)` |
|
||||
| `result_set('CODE', value)` | Target a specific test by `TestSiteCode`, allowing multiple tests to be updated in one rule | `result_set('tesA', 0.5)` |
|
||||
| `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesn’t already exist for the order | `test_insert('HBA1C')` |
|
||||
| `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` |
|
||||
| `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
|
||||
| `nothing` | Explicit no-op to terminate an action chain | `nothing` |
|
||||
|
||||
> **Note:** `set_priority()` was removed. Use `comment_insert()` for priority notes without altering billing.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`).
|
||||
2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`.
|
||||
3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). When you provide a `TestSiteCode` as the first argument (`result_set('tesA', value)`), the engine resolves that code before writing the result. `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed.
|
||||
4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`.
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
if(sex('M'); result_set('tesA', 0.5):result_set('tesB', 1.2); result_set('tesA', 0.6):result_set('tesB', 1.0))
|
||||
```
|
||||
Sets both `tesA`/`tesB` results together per branch.
|
||||
|
||||
```
|
||||
if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)
|
||||
```
|
||||
Adds new tests when glucose is already requested.
|
||||
|
||||
```
|
||||
if(sex('M') && age > 40; result_set(1.2); result_set(1.0))
|
||||
```
|
||||
|
||||
```
|
||||
if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))
|
||||
```
|
||||
|
||||
```
|
||||
if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))
|
||||
```
|
||||
|
||||
```
|
||||
if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)
|
||||
```
|
||||
|
||||
```
|
||||
if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))
|
||||
```
|
||||
|
||||
```
|
||||
if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin request removed'); nothing)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints live under `/api/rule` and accept JSON. Responses use the standard `{ status, message, data }` envelope.
|
||||
|
||||
### List Rules
|
||||
|
||||
```http
|
||||
GET /api/rule?EventCode=test_created&TestSiteID=12&search=glucose
|
||||
```
|
||||
|
||||
Query Params:
|
||||
|
||||
- `EventCode` (optional) filter by event code.
|
||||
- `TestSiteID` (optional) filter rules linked to a test site.
|
||||
- `search` (optional) partial match against `RuleName`.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "fetch success",
|
||||
"data": [
|
||||
{
|
||||
"RuleID": 1,
|
||||
"RuleCode": "RULE_001",
|
||||
"RuleName": "Sex-based result",
|
||||
"EventCode": "test_created",
|
||||
"ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))",
|
||||
"ConditionExprCompiled": "{...}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Rule
|
||||
|
||||
```http
|
||||
GET /api/rule/1
|
||||
```
|
||||
|
||||
Response includes `linkedTests`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "fetch success",
|
||||
"data": {
|
||||
"RuleID": 1,
|
||||
"RuleCode": "RULE_001",
|
||||
"RuleName": "Sex-based result",
|
||||
"EventCode": "test_created",
|
||||
"ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))",
|
||||
"ConditionExprCompiled": "{...}",
|
||||
"linkedTests": [1, 2]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Rule
|
||||
|
||||
```http
|
||||
POST /api/rule
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"RuleCode": "RULE_001",
|
||||
"RuleName": "Sex-based result",
|
||||
"EventCode": "test_created",
|
||||
"ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))",
|
||||
"ConditionExprCompiled": "<compiled JSON here>",
|
||||
"TestSiteIDs": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Rule created successfully",
|
||||
"data": {
|
||||
"RuleID": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rule
|
||||
|
||||
```http
|
||||
PATCH /api/rule/1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"RuleName": "Sex-based result v2",
|
||||
"ConditionExpr": "if(sex('M'); result_set(0.7); result_set(0.6))",
|
||||
"ConditionExprCompiled": "<compiled JSON here>",
|
||||
"TestSiteIDs": [1, 3]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Rule updated successfully",
|
||||
"data": {
|
||||
"RuleID": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Rule
|
||||
|
||||
```http
|
||||
DELETE /api/rule/1
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Rule deleted successfully",
|
||||
"data": {
|
||||
"RuleID": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compile DSL
|
||||
|
||||
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
|
||||
|
||||
```http
|
||||
POST /api/rule/compile
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expr": "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"raw": "if(sex('M'); result_set(0.5); result_set(0.6))",
|
||||
"compiled": {
|
||||
"conditionExpr": "sex('M')",
|
||||
"then": ["result_set(0.5)"],
|
||||
"else": ["result_set(0.6)"]
|
||||
},
|
||||
"conditionExprCompiled": "{...}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Evaluate Expression (Validation)
|
||||
|
||||
This endpoint evaluates an expression against a runtime context. It does not compile DSL or persist the result.
|
||||
|
||||
```http
|
||||
POST /api/rule/validate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expr": "order[\"Age\"] > 18",
|
||||
"context": {
|
||||
"order": {
|
||||
"Age": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"valid": true,
|
||||
"result": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
- **ruledef** – stores rule metadata, raw DSL, and compiled JSON.
|
||||
- **testrule** – mapping table that links rules to tests via `TestSiteID`.
|
||||
- **ruleaction** – deprecated. Actions are now embedded in `ConditionExprCompiled`.
|
||||
|
||||
### Key Columns
|
||||
|
||||
| Column | Table | Description |
|
||||
|--------|-------|-------------|
|
||||
| `EventCode` | ruledef | The trigger event (typically `test_created` or `result_updated`). |
|
||||
| `ConditionExpr` | ruledef | Raw DSL expression (semicolon syntax). |
|
||||
| `ConditionExprCompiled` | ruledef | JSON payload consumed at runtime (`then`, `else`, etc.). |
|
||||
| `ActionType` / `ActionParams` | ruleaction | Deprecated; actions live in compiled JSON now. |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always run `POST /api/rule/compile` before persisting a rule so `ConditionExprCompiled` exists.
|
||||
2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests.
|
||||
3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed.
|
||||
4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions.
|
||||
5. Group complex boolean logic with parentheses for clarity when mixing `&&` and `||`.
|
||||
6. Use `requested('CODE')` responsibly; it performs a database lookup on `patres` so avoid invoking it in high-frequency loops without reason.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Syntax Changes (v2.0)
|
||||
|
||||
The DSL moved from ternary (`condition ? action : action`) to semicolon syntax. Existing rules must be migrated via the provided script.
|
||||
|
||||
| Old Syntax | New Syntax |
|
||||
|------------|------------|
|
||||
| `if(condition ? action : action)` | `if(condition; action; action)` |
|
||||
|
||||
#### Migration Examples
|
||||
|
||||
```
|
||||
# BEFORE
|
||||
if(sex('M') ? result_set(0.5) : result_set(0.6))
|
||||
|
||||
# AFTER
|
||||
if(sex('M'); result_set(0.5); result_set(0.6))
|
||||
```
|
||||
|
||||
```
|
||||
# BEFORE
|
||||
if(sex('F') ? set_priority('S') : nothing)
|
||||
|
||||
# AFTER
|
||||
if(sex('F'); comment_insert('Female patient - review priority'); nothing)
|
||||
```
|
||||
|
||||
#### Migration Process
|
||||
|
||||
Run the migration which:
|
||||
|
||||
1. Converts ternary syntax to semicolon syntax.
|
||||
2. Recompiles every expression into JSON so the engine consumes `ConditionExprCompiled` directly.
|
||||
3. Eliminates reliance on the `ruleaction` table.
|
||||
|
||||
```bash
|
||||
php spark migrate
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rule Not Executing
|
||||
|
||||
1. Ensure the rule has a compiled payload (`ConditionExprCompiled`).
|
||||
2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`.
|
||||
3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`).
|
||||
4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution).
|
||||
5. Use `/api/rule/compile` to validate the DSL and view errors.
|
||||
|
||||
### Invalid Expression
|
||||
|
||||
1. POST the expression to `/api/rule/compile` to get a detailed compilation error.
|
||||
2. If using `/api/rule/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
|
||||
|
||||
### Runtime Errors
|
||||
|
||||
- `RESULT_SET requires context.order.InternalOID` or `testSiteID`: include those fields in the context passed to `RuleEngineService::run()`.
|
||||
- `TEST_INSERT` failures mean the provided `TestSiteCode` does not exist or the rule attempted to insert a duplicate test; check `testdefsite` and existing `patres` rows.
|
||||
- `COMMENT_INSERT requires comment`: ensure the action provides text.
|
||||
11
src/lib/api/calculator.js
Normal file
11
src/lib/api/calculator.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { post } from './client.js';
|
||||
|
||||
/**
|
||||
* Evaluate a configured calculation for a test site by forwarding the captured variables.
|
||||
* @param {number} testSiteId - Identifier of the test site (TestSiteID) whose calculation definition should be used.
|
||||
* @param {Record<string, number|string>} [variables={}] - Input values required by the configured formula.
|
||||
* @returns {Promise<any>} API response from `/api/calc/testsite/{testSiteId}`.
|
||||
*/
|
||||
export async function calculateByTestSite(testSiteId, variables = {}) {
|
||||
return post(`/api/calc/testsite/${testSiteId}`, variables);
|
||||
}
|
||||
@ -13,8 +13,8 @@ export async function createContact(data) {
|
||||
return post('/api/contact', data);
|
||||
}
|
||||
|
||||
export async function updateContact(data) {
|
||||
return patch('/api/contact', data);
|
||||
export async function updateContact(id, data) {
|
||||
return patch(`/api/contact/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteContact(id) {
|
||||
|
||||
@ -21,9 +21,8 @@ export async function createContainer(data) {
|
||||
return post('/api/specimen/container', payload);
|
||||
}
|
||||
|
||||
export async function updateContainer(data) {
|
||||
export async function updateContainer(id, data) {
|
||||
const payload = {
|
||||
ConDefID: data.ConDefID,
|
||||
ConCode: data.ConCode,
|
||||
ConName: data.ConName,
|
||||
ConDesc: data.ConDesc,
|
||||
@ -31,7 +30,7 @@ export async function updateContainer(data) {
|
||||
Additive: data.Additive,
|
||||
Color: data.Color,
|
||||
};
|
||||
return patch('/api/specimen/container', payload);
|
||||
return patch(`/api/specimen/container/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteContainer(id) {
|
||||
|
||||
@ -13,8 +13,8 @@ export async function createCounter(data) {
|
||||
return post('/api/counter', data);
|
||||
}
|
||||
|
||||
export async function updateCounter(data) {
|
||||
return patch('/api/counter', data);
|
||||
export async function updateCounter(id, data) {
|
||||
return patch(`/api/counter/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteCounter(id) {
|
||||
|
||||
@ -62,9 +62,8 @@ export async function createEquipment(data) {
|
||||
* @param {number} [data.WorkstationID] - Workstation ID
|
||||
* @returns {Promise<Object>} Updated equipment ID
|
||||
*/
|
||||
export async function updateEquipment(data) {
|
||||
export async function updateEquipment(id, data) {
|
||||
const payload = {
|
||||
EID: data.EID,
|
||||
IEID: data.IEID,
|
||||
DepartmentID: data.DepartmentID,
|
||||
Enable: data.Enable,
|
||||
@ -73,7 +72,7 @@ export async function updateEquipment(data) {
|
||||
InstrumentName: data.InstrumentName || null,
|
||||
WorkstationID: data.WorkstationID || null,
|
||||
};
|
||||
return patch('/api/equipmentlist', payload);
|
||||
return patch(`/api/equipmentlist/${id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -19,15 +19,14 @@ export async function createLocation(data) {
|
||||
return post('/api/location', payload);
|
||||
}
|
||||
|
||||
export async function updateLocation(data) {
|
||||
export async function updateLocation(id, data) {
|
||||
const payload = {
|
||||
LocationID: data.LocationID,
|
||||
LocCode: data.Code,
|
||||
LocFull: data.Name,
|
||||
LocType: data.Type,
|
||||
Parent: data.ParentID,
|
||||
};
|
||||
return patch('/api/location', payload);
|
||||
return patch(`/api/location/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteLocation(id) {
|
||||
|
||||
@ -18,12 +18,11 @@ export async function createOccupation(data) {
|
||||
return post('/api/occupation', payload);
|
||||
}
|
||||
|
||||
export async function updateOccupation(data) {
|
||||
export async function updateOccupation(id, data) {
|
||||
const payload = {
|
||||
OccupationID: data.OccupationID,
|
||||
OccCode: data.OccCode,
|
||||
OccText: data.OccText,
|
||||
Description: data.Description,
|
||||
};
|
||||
return patch('/api/occupation', payload);
|
||||
return patch(`/api/occupation/${id}`, payload);
|
||||
}
|
||||
|
||||
@ -53,8 +53,8 @@ export async function createOrder(data) {
|
||||
* @param {number} [data.WorkstationID] - Workstation ID
|
||||
* @returns {Promise<Object>} API response with updated order data
|
||||
*/
|
||||
export async function updateOrder(data) {
|
||||
return patch('/api/ordertest', data);
|
||||
export async function updateOrder(id, data) {
|
||||
return patch(`/api/ordertest/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -21,16 +21,15 @@ export async function createDiscipline(data) {
|
||||
return post('/api/organization/discipline', payload);
|
||||
}
|
||||
|
||||
export async function updateDiscipline(data) {
|
||||
export async function updateDiscipline(id, data) {
|
||||
const payload = {
|
||||
id: data.DisciplineID,
|
||||
DisciplineCode: data.DisciplineCode,
|
||||
DisciplineName: data.DisciplineName,
|
||||
Parent: data.Parent || null,
|
||||
SeqScr: data.SeqScr,
|
||||
SeqRpt: data.SeqRpt,
|
||||
};
|
||||
return patch('/api/organization/discipline', payload);
|
||||
return patch(`/api/organization/discipline/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteDiscipline(id) {
|
||||
@ -56,14 +55,13 @@ export async function createDepartment(data) {
|
||||
return post('/api/organization/department', payload);
|
||||
}
|
||||
|
||||
export async function updateDepartment(data) {
|
||||
export async function updateDepartment(id, data) {
|
||||
const payload = {
|
||||
id: data.DepartmentID,
|
||||
DeptCode: data.DeptCode,
|
||||
DeptName: data.DeptName,
|
||||
SiteID: data.SiteID,
|
||||
};
|
||||
return patch('/api/organization/department', payload);
|
||||
return patch(`/api/organization/department/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteDepartment(id) {
|
||||
@ -89,14 +87,13 @@ export async function createSite(data) {
|
||||
return post('/api/organization/site', payload);
|
||||
}
|
||||
|
||||
export async function updateSite(data) {
|
||||
export async function updateSite(id, data) {
|
||||
const payload = {
|
||||
id: data.SiteID,
|
||||
SiteCode: data.SiteCode,
|
||||
SiteName: data.SiteName,
|
||||
AccountID: data.AccountID,
|
||||
};
|
||||
return patch('/api/organization/site', payload);
|
||||
return patch(`/api/organization/site/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteSite(id) {
|
||||
@ -131,13 +128,12 @@ export async function createHostApp(data) {
|
||||
return post('/api/organization/hostapp', payload);
|
||||
}
|
||||
|
||||
export async function updateHostApp(data) {
|
||||
export async function updateHostApp(id, data) {
|
||||
const payload = {
|
||||
id: data.HostAppID,
|
||||
HostAppName: data.HostAppName,
|
||||
SiteID: data.SiteID,
|
||||
};
|
||||
return patch('/api/organization/hostapp', payload);
|
||||
return patch(`/api/organization/hostapp/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteHostApp(id) {
|
||||
@ -164,15 +160,14 @@ export async function createHostComPara(data) {
|
||||
return post('/api/organization/hostcompara', payload);
|
||||
}
|
||||
|
||||
export async function updateHostComPara(data) {
|
||||
export async function updateHostComPara(id, data) {
|
||||
const payload = {
|
||||
id: data.HostComParaID,
|
||||
HostAppID: data.HostAppID,
|
||||
HostIP: data.HostIP,
|
||||
HostPort: data.HostPort,
|
||||
HostPwd: data.HostPwd,
|
||||
};
|
||||
return patch('/api/organization/hostcompara', payload);
|
||||
return patch(`/api/organization/hostcompara/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteHostComPara(id) {
|
||||
@ -198,14 +193,13 @@ export async function createCodingSystem(data) {
|
||||
return post('/api/organization/codingsys', payload);
|
||||
}
|
||||
|
||||
export async function updateCodingSystem(data) {
|
||||
export async function updateCodingSystem(id, data) {
|
||||
const payload = {
|
||||
id: data.CodingSysID,
|
||||
CodingSysAbb: data.CodingSysAbb,
|
||||
FullText: data.FullText,
|
||||
Description: data.Description,
|
||||
};
|
||||
return patch('/api/organization/codingsys', payload);
|
||||
return patch(`/api/organization/codingsys/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteCodingSystem(id) {
|
||||
@ -232,14 +226,13 @@ export async function createAccount(data) {
|
||||
return post('/api/organization/account', payload);
|
||||
}
|
||||
|
||||
export async function updateAccount(data) {
|
||||
export async function updateAccount(id, data) {
|
||||
const payload = {
|
||||
id: data.AccountID,
|
||||
AccountName: data.AccountName,
|
||||
Initial: data.Initial,
|
||||
Parent: data.Parent,
|
||||
};
|
||||
return patch('/api/organization/account', payload);
|
||||
return patch(`/api/organization/account/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteAccount(id) {
|
||||
@ -257,15 +250,14 @@ export async function createWorkstation(data) {
|
||||
return post('/api/organization/workstation', payload);
|
||||
}
|
||||
|
||||
export async function updateWorkstation(data) {
|
||||
export async function updateWorkstation(id, data) {
|
||||
const payload = {
|
||||
id: data.WorkstationID,
|
||||
WorkstationCode: data.WorkstationCode,
|
||||
WorkstationName: data.WorkstationName,
|
||||
SiteID: data.SiteID,
|
||||
DepartmentID: data.DepartmentID,
|
||||
};
|
||||
return patch('/api/organization/workstation', payload);
|
||||
return patch(`/api/organization/workstation/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteWorkstation(id) {
|
||||
|
||||
@ -84,8 +84,8 @@ export async function createPatient(data) {
|
||||
* @param {Object} data - Patient data (must include PatientID)
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function updatePatient(data) {
|
||||
return patch('/api/patient', data);
|
||||
export async function updatePatient(id, data) {
|
||||
return patch(`/api/patient/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -21,7 +21,7 @@ import { get, post, patch, del } from './client.js';
|
||||
*/
|
||||
export async function fetchRules(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/rules?${query}` : '/api/rules');
|
||||
return get(query ? `/api/rule?${query}` : '/api/rule');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -30,7 +30,7 @@ export async function fetchRules(params = {}) {
|
||||
* @returns {Promise<RuleDetailResponse>}
|
||||
*/
|
||||
export async function fetchRule(id) {
|
||||
return get(`/api/rules/${id}`);
|
||||
return get(`/api/rule/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -39,7 +39,7 @@ export async function fetchRule(id) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function createRule(payload) {
|
||||
return post('/api/rules', payload);
|
||||
return post('/api/rule', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,7 +49,7 @@ export async function createRule(payload) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function updateRule(id, payload) {
|
||||
return patch(`/api/rules/${id}`, payload);
|
||||
return patch(`/api/rule/${id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -58,7 +58,7 @@ export async function updateRule(id, payload) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function deleteRule(id) {
|
||||
return del(`/api/rules/${id}`);
|
||||
return del(`/api/rule/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -68,7 +68,7 @@ export async function deleteRule(id) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function linkTestToRule(ruleId, testSiteId) {
|
||||
return post(`/api/rules/${ruleId}/link`, { TestSiteID: testSiteId });
|
||||
return post(`/api/rule/${ruleId}/link`, { TestSiteID: testSiteId });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -78,7 +78,7 @@ export async function linkTestToRule(ruleId, testSiteId) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function unlinkTestFromRule(ruleId, testSiteId) {
|
||||
return post(`/api/rules/${ruleId}/unlink`, { TestSiteID: testSiteId });
|
||||
return post(`/api/rule/${ruleId}/unlink`, { TestSiteID: testSiteId });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -88,7 +88,7 @@ export async function unlinkTestFromRule(ruleId, testSiteId) {
|
||||
* @returns {Promise<ValidateExprResponse>}
|
||||
*/
|
||||
export async function validateExpression(expr, context = {}) {
|
||||
return post('/api/rules/validate', { expr, context });
|
||||
return post('/api/rule/validate', { expr, context });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -97,7 +97,7 @@ export async function validateExpression(expr, context = {}) {
|
||||
* @returns {Promise<{ compiled: any; conditionExprCompiled: string }>}
|
||||
*/
|
||||
export async function compileRuleExpr(expr) {
|
||||
return post('/api/rules/compile', { expr });
|
||||
return post('/api/rule/compile', { expr });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,7 +106,7 @@ export async function compileRuleExpr(expr) {
|
||||
* @returns {Promise<RuleActionsListResponse>}
|
||||
*/
|
||||
export async function fetchRuleActions(id) {
|
||||
return get(`/api/rules/${id}/actions`);
|
||||
return get(`/api/rule/${id}/actions`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,7 +116,7 @@ export async function fetchRuleActions(id) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function createRuleAction(id, payload) {
|
||||
return post(`/api/rules/${id}/actions`, payload);
|
||||
return post(`/api/rule/${id}/actions`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,7 +127,7 @@ export async function createRuleAction(id, payload) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function updateRuleAction(id, actionId, payload) {
|
||||
return patch(`/api/rules/${id}/actions/${actionId}`, payload);
|
||||
return patch(`/api/rule/${id}/actions/${actionId}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,5 +137,5 @@ export async function updateRuleAction(id, actionId, payload) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function deleteRuleAction(id, actionId) {
|
||||
return del(`/api/rules/${id}/actions/${actionId}`);
|
||||
return del(`/api/rule/${id}/actions/${actionId}`);
|
||||
}
|
||||
@ -13,8 +13,8 @@ export async function createSpecialty(data) {
|
||||
return post('/api/medicalspecialty', data);
|
||||
}
|
||||
|
||||
export async function updateSpecialty(data) {
|
||||
return patch('/api/medicalspecialty', data);
|
||||
export async function updateSpecialty(id, data) {
|
||||
return patch(`/api/medicalspecialty/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpecialty(id) {
|
||||
|
||||
@ -150,8 +150,8 @@ export async function createTestMap(data) {
|
||||
* @param {UpdateTestMapPayload} data - Header data
|
||||
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response
|
||||
*/
|
||||
export async function updateTestMap(data) {
|
||||
return patch('/api/test/testmap', data);
|
||||
export async function updateTestMap(id, data) {
|
||||
return patch(`/api/test/testmap/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -210,8 +210,8 @@ export async function createTestMapDetail(data) {
|
||||
* @param {UpdateTestMapDetailPayload} data - Detail data
|
||||
* @returns {Promise<{success: boolean, message?: string}>} API response
|
||||
*/
|
||||
export async function updateTestMapDetail(data) {
|
||||
return patch('/api/test/testmap/detail', data);
|
||||
export async function updateTestMapDetail(id, data) {
|
||||
return patch(`/api/test/testmap/detail/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -177,9 +177,9 @@ export async function createTest(formData) {
|
||||
* @param {any} formData - The form state
|
||||
* @returns {Promise<UpdateTestResponse>} API response
|
||||
*/
|
||||
export async function updateTest(formData) {
|
||||
export async function updateTest(id, formData) {
|
||||
const payload = buildPayload(formData, true);
|
||||
return patch('/api/test', payload);
|
||||
return patch(`/api/test/${id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -17,8 +17,8 @@ export async function createVisit(data) {
|
||||
return post('/api/patvisit', data);
|
||||
}
|
||||
|
||||
export async function updateVisit(data) {
|
||||
return patch('/api/patvisit', data);
|
||||
export async function updateVisit(id, data) {
|
||||
return patch(`/api/patvisit/${encodeURIComponent(id)}`, data);
|
||||
}
|
||||
|
||||
export async function deleteVisit(id) {
|
||||
@ -29,8 +29,8 @@ export async function createADT(data) {
|
||||
return post('/api/patvisitadt', data);
|
||||
}
|
||||
|
||||
export async function updateADT(data) {
|
||||
return patch('/api/patvisitadt', data);
|
||||
export async function updateADT(id, data) {
|
||||
return patch(`/api/patvisitadt/${encodeURIComponent(id)}`, data);
|
||||
}
|
||||
|
||||
export async function fetchVisitADTHistory(visitId) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { compileRuleExpr, createRule, updateRule, fetchRule } from '$lib/api/rules.js';
|
||||
import { compileRuleExpr, createRule, updateRule, fetchRule } from '$lib/api/rule.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { ArrowLeft, Save, Loader2, AlertCircle, CheckCircle2, RefreshCw, Code } from 'lucide-svelte';
|
||||
|
||||
|
||||
@ -125,7 +125,7 @@
|
||||
await createContact(dataToSave);
|
||||
toastSuccess('Contact created successfully');
|
||||
} else {
|
||||
await updateContact(dataToSave);
|
||||
await updateContact(dataToSave.ContactID, dataToSave);
|
||||
toastSuccess('Contact updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -113,7 +113,7 @@
|
||||
await createContainer(formData);
|
||||
toastSuccess('Container created successfully');
|
||||
} else {
|
||||
await updateContainer(formData);
|
||||
await updateContainer(formData.ConDefID, formData);
|
||||
toastSuccess('Container updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -161,7 +161,7 @@
|
||||
await createCounter(formData);
|
||||
toastSuccess('Counter created successfully');
|
||||
} else {
|
||||
await updateCounter(formData);
|
||||
await updateCounter(formData.CounterID, formData);
|
||||
toastSuccess('Counter updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
await createLocation(formData);
|
||||
toastSuccess('Location created successfully');
|
||||
} else {
|
||||
await updateLocation(formData);
|
||||
await updateLocation(formData.LocationID, formData);
|
||||
toastSuccess('Location updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
await createOccupation(formData);
|
||||
toastSuccess('Occupation created successfully');
|
||||
} else {
|
||||
await updateOccupation(formData);
|
||||
await updateOccupation(formData.OccupationID, formData);
|
||||
toastSuccess('Occupation updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -136,7 +136,7 @@
|
||||
await createEquipment(formData);
|
||||
toastSuccess('Equipment created successfully');
|
||||
} else {
|
||||
await updateEquipment(formData);
|
||||
await updateEquipment(formData.EID, formData);
|
||||
toastSuccess('Equipment updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -130,7 +130,7 @@
|
||||
await createWorkstation(formData);
|
||||
toastSuccess('Workstation created successfully');
|
||||
} else {
|
||||
await updateWorkstation(formData);
|
||||
await updateWorkstation(formData.WorkstationID, formData);
|
||||
toastSuccess('Workstation updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -95,7 +95,7 @@
|
||||
await createSpecialty(formData);
|
||||
toastSuccess('Specialty created successfully');
|
||||
} else {
|
||||
await updateSpecialty(formData);
|
||||
await updateSpecialty(formData.SpecialtyID, formData);
|
||||
toastSuccess('Specialty updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Info, Settings, Calculator, Users, Link, Hash, Type, AlertTriangle } from 'lucide-svelte';
|
||||
import { Info, Settings, Calculator, Users, Link, Hash, Type, AlertTriangle, FileText } from 'lucide-svelte';
|
||||
import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -12,6 +12,7 @@
|
||||
import RefNumTab from './tabs/RefNumTab.svelte';
|
||||
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
||||
import ThresholdTab from './tabs/ThresholdTab.svelte';
|
||||
import RulesTab from './tabs/RulesTab.svelte';
|
||||
|
||||
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
||||
|
||||
@ -36,7 +37,8 @@ import ThresholdTab from './tabs/ThresholdTab.svelte';
|
||||
{ id: 'mappings', label: 'Mappings', component: Link },
|
||||
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
||||
{ id: 'threshold', label: 'Thresholds', component: AlertTriangle },
|
||||
{ id: 'reftxt', label: 'Txt Refs', component: Type }
|
||||
{ id: 'reftxt', label: 'Txt Refs', component: Type },
|
||||
{ id: 'rules', label: 'Rules', component: FileText }
|
||||
];
|
||||
|
||||
const visibleTabs = $derived.by(() => {
|
||||
@ -61,6 +63,9 @@ import ThresholdTab from './tabs/ThresholdTab.svelte';
|
||||
// Show for TEST/PARAM with TEXT result type
|
||||
return ['TEST', 'PARAM'].includes(type) && resultType === 'TEXT' && refType === 'TEXT';
|
||||
}
|
||||
if (tab.id === 'rules') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@ -437,6 +442,12 @@ import ThresholdTab from './tabs/ThresholdTab.svelte';
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{:else if currentTab === 'rules'}
|
||||
<RulesTab
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
{mode}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,316 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AlertCircle, Code, Loader2, RefreshCw, Save } from 'lucide-svelte';
|
||||
import { compileRuleExpr, createRule, updateRule } from '$lib/api/rule.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { open = $bindable(false), testSiteId = null, ruleMode = 'create', initialRule = null } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let ruleCode = $state('');
|
||||
let ruleName = $state('');
|
||||
let eventCode = $state('test_created');
|
||||
let rawExpr = $state('');
|
||||
|
||||
let compiledExprJson = $state(null);
|
||||
let compiledExprObject = $state(null);
|
||||
let compileStatus = $state('idle');
|
||||
let compileError = $state(null);
|
||||
let lastCompiledRawExpr = $state(null);
|
||||
|
||||
let isSaving = $state(false);
|
||||
let formErrors = $state({});
|
||||
let hasTriedSave = $state(false);
|
||||
let saveMessage = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
initializeForm();
|
||||
});
|
||||
|
||||
function initializeForm() {
|
||||
ruleCode = initialRule?.RuleCode || '';
|
||||
ruleName = initialRule?.RuleName || '';
|
||||
eventCode = initialRule?.EventCode || 'test_created';
|
||||
rawExpr = initialRule?.ConditionExpr || '';
|
||||
compiledExprJson = initialRule?.ConditionExprCompiled || null;
|
||||
compiledExprObject = parseCompiled(compiledExprJson);
|
||||
compileStatus = compiledExprJson ? 'success' : 'idle';
|
||||
lastCompiledRawExpr = compiledExprJson ? rawExpr : null;
|
||||
compileError = null;
|
||||
formErrors = {};
|
||||
hasTriedSave = false;
|
||||
}
|
||||
|
||||
function parseCompiled(payload) {
|
||||
if (!payload) return null;
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRawExprChange(value) {
|
||||
rawExpr = value;
|
||||
if (lastCompiledRawExpr !== null && lastCompiledRawExpr !== value) {
|
||||
compileStatus = 'stale';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompileClick() {
|
||||
if (!rawExpr.trim()) {
|
||||
compileError = 'Expression cannot be empty';
|
||||
compileStatus = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
compileStatus = 'loading';
|
||||
compileError = null;
|
||||
|
||||
try {
|
||||
const response = await compileRuleExpr(rawExpr);
|
||||
const data = response?.data?.data ?? response?.data ?? response;
|
||||
|
||||
if (data?.conditionExprCompiled) {
|
||||
compiledExprJson = data.conditionExprCompiled;
|
||||
compiledExprObject = data.compiled ?? null;
|
||||
compileStatus = 'success';
|
||||
lastCompiledRawExpr = rawExpr;
|
||||
} else {
|
||||
throw new Error('Invalid compile response');
|
||||
}
|
||||
} catch (err) {
|
||||
compileError = err?.message || 'Compilation failed';
|
||||
compileStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const errors = {};
|
||||
if (!ruleCode.trim()) errors.RuleCode = 'Rule code is required';
|
||||
if (!ruleName.trim()) errors.RuleName = 'Rule name is required';
|
||||
if (!eventCode.trim()) errors.EventCode = 'Event code is required';
|
||||
formErrors = errors;
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
function isSaveDisabled() {
|
||||
if (isSaving) return true;
|
||||
if (!testSiteId) return true;
|
||||
if (!rawExpr.trim()) return false;
|
||||
if (compileStatus !== 'success') return true;
|
||||
if (lastCompiledRawExpr !== rawExpr) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSaveMessage() {
|
||||
if (!hasTriedSave) return null;
|
||||
if (!testSiteId) return 'Save the test first to link rules';
|
||||
if (isSaving) return 'Saving rule...';
|
||||
if (compileStatus === 'loading') return 'Compiling before save';
|
||||
if (compileStatus === 'error') return 'Fix compilation errors';
|
||||
if (compileStatus === 'stale') return 'Recompile before saving';
|
||||
if (!rawExpr.trim()) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
hasTriedSave = true;
|
||||
if (!validateForm()) return;
|
||||
if (isSaveDisabled()) return;
|
||||
isSaving = true;
|
||||
|
||||
const payload = {
|
||||
RuleCode: ruleCode.trim(),
|
||||
RuleName: ruleName.trim(),
|
||||
EventCode: eventCode,
|
||||
ConditionExpr: rawExpr.trim() || null,
|
||||
ConditionExprCompiled: rawExpr.trim() ? compiledExprJson : null
|
||||
};
|
||||
|
||||
try {
|
||||
if (ruleMode === 'edit' && initialRule?.RuleID) {
|
||||
await updateRule(initialRule.RuleID, { ...payload, TestSiteIDs: [testSiteId] });
|
||||
toastSuccess('Rule updated');
|
||||
} else {
|
||||
await createRule({ ...payload, TestSiteIDs: [testSiteId] });
|
||||
toastSuccess('Rule created');
|
||||
}
|
||||
|
||||
dispatch('saved');
|
||||
open = false;
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Failed to save rule';
|
||||
toastError(message);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function getStatusBadge() {
|
||||
switch (compileStatus) {
|
||||
case 'loading':
|
||||
return { text: 'Compiling', class: 'badge-info' };
|
||||
case 'success':
|
||||
return { text: 'Compiled', class: 'badge-success' };
|
||||
case 'stale':
|
||||
return { text: 'Stale', class: 'badge-warning' };
|
||||
case 'error':
|
||||
return { text: 'Error', class: 'badge-error' };
|
||||
default:
|
||||
return { text: 'Not compiled', class: 'badge-ghost' };
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge = $derived(getStatusBadge);
|
||||
const saveDisabled = $derived(isSaveDisabled);
|
||||
|
||||
$effect(() => {
|
||||
saveMessage = getSaveMessage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
size="lg"
|
||||
title={ruleMode === 'edit' ? 'Edit Rule' : 'New Rule'}
|
||||
>
|
||||
{#if !testSiteId}
|
||||
<div class="alert alert-warning text-sm">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
Save the test first, so we can link rules by `TestSiteID`.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label for="ruleCode" class="label-text text-sm font-medium">Rule Code</label>
|
||||
<div class="input input-bordered input-sm flex items-center gap-2">
|
||||
<Code class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="ruleCode"
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
value={ruleCode}
|
||||
oninput={(event) => (ruleCode = event.currentTarget.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
{#if formErrors.RuleCode}
|
||||
<p class="text-xs text-error">{formErrors.RuleCode}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="ruleName" class="label-text text-sm font-medium">Rule Name</label>
|
||||
<div class="input input-bordered input-sm flex items-center gap-2">
|
||||
<Code class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="ruleName"
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
value={ruleName}
|
||||
oninput={(event) => (ruleName = event.currentTarget.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
{#if formErrors.RuleName}
|
||||
<p class="text-xs text-error">{formErrors.RuleName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="eventCode" class="label-text text-sm font-medium">Event Code</label>
|
||||
<select
|
||||
id="eventCode"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={eventCode}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="test_created">test_created</option>
|
||||
<option value="result_updated">result_updated</option>
|
||||
<option value="ORDER_CREATED">ORDER_CREATED</option>
|
||||
</select>
|
||||
{#if formErrors.EventCode}
|
||||
<p class="text-xs text-error">{formErrors.EventCode}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="conditionExpr" class="label-text text-sm font-medium">Condition Expression</label>
|
||||
<span class="badge badge-sm {statusBadge.class}">{statusBadge.text}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="conditionExpr"
|
||||
rows="4"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"
|
||||
value={rawExpr}
|
||||
oninput={(event) => handleRawExprChange(event.currentTarget.value)}
|
||||
placeholder="if(sex('M'); result_set('HGB', 14); nothing)"
|
||||
disabled={isSaving}
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<small class="text-xs text-gray-500">Use the DSL syntax described in <a href={resolve('/docs/test-rule-engine.md')} class="text-primary underline">docs</a>.</small>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
type="button"
|
||||
onclick={handleCompileClick}
|
||||
disabled={isSaving || compileStatus === 'loading' || !rawExpr.trim()}
|
||||
>
|
||||
{#if compileStatus === 'loading'}
|
||||
<Loader2 class="w-3 h-3 animate-spin mr-1" />
|
||||
Compiling...
|
||||
{:else}
|
||||
<RefreshCw class="w-3 h-3 mr-1" />
|
||||
Compile
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if compileError}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
<span class="text-xs">{compileError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if compiledExprObject && compileStatus === 'success'}
|
||||
<div class="bg-base-100 border border-base-200 rounded-lg p-3 text-xs text-gray-600">
|
||||
<p class="font-semibold text-gray-700 mb-1">Compiled preview</p>
|
||||
<pre class="font-mono text-[11px] overflow-auto max-h-40">{JSON.stringify(compiledExprObject, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" type="button" onclick={handleCancel} disabled={isSaving}>Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={saveDisabled}
|
||||
>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
{ruleMode === 'edit' ? 'Update Rule' : 'Create Rule'}
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if saveMessage}
|
||||
<div class="text-xs text-warning mt-2">{saveMessage}</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
@ -140,6 +140,7 @@
|
||||
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500">Supported operators/functions are listed in <a href="/docs/calculator-operators.md" class="text-primary underline">docs/calculator-operators.md</a>.</p>
|
||||
|
||||
<!-- Formula Code -->
|
||||
<div class="space-y-2">
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
<script>
|
||||
import { AlertTriangle, Loader2, Plus, RefreshCw } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fetchRules, unlinkTestFromRule } from '$lib/api/rule.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import RuleEditorModal from '../modals/RuleEditorModal.svelte';
|
||||
|
||||
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
|
||||
|
||||
let rules = $state([]);
|
||||
let loading = $state(false);
|
||||
let editorOpen = $state(false);
|
||||
let editorMode = $state('create');
|
||||
let selectedRule = $state(null);
|
||||
let lastLoadedTestId = $state(null);
|
||||
|
||||
const hasTestId = $derived.by(() => formData.TestSiteID !== null && formData.TestSiteID !== undefined);
|
||||
const rulesCount = $derived.by(() => rules.length);
|
||||
|
||||
function normalizeRulesListResponse(response) {
|
||||
if (Array.isArray(response?.data?.data)) return response.data.data;
|
||||
if (Array.isArray(response?.data)) return response.data;
|
||||
if (Array.isArray(response?.data?.rules)) return response.data.rules;
|
||||
return [];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!hasTestId) {
|
||||
rules = [];
|
||||
lastLoadedTestId = null;
|
||||
return;
|
||||
}
|
||||
loadRules();
|
||||
});
|
||||
|
||||
async function loadRules(force = false) {
|
||||
if (!hasTestId) return;
|
||||
const testId = formData.TestSiteID;
|
||||
if (!force && lastLoadedTestId === testId) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchRules({ TestSiteID: testId });
|
||||
rules = normalizeRulesListResponse(response).filter(Boolean);
|
||||
lastLoadedTestId = testId;
|
||||
} catch (err) {
|
||||
toastError(err?.message || 'Unable to load rules for this test');
|
||||
rules = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(mode = 'create', rule = null) {
|
||||
editorMode = mode;
|
||||
selectedRule = rule;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function handleEditorSaved() {
|
||||
isDirty = true;
|
||||
handleEditorClose();
|
||||
loadRules(true);
|
||||
}
|
||||
|
||||
function handleEditorClose() {
|
||||
editorOpen = false;
|
||||
selectedRule = null;
|
||||
}
|
||||
|
||||
async function handleUnlink(rule) {
|
||||
if (!hasTestId) return;
|
||||
if (!confirm('Unlinking removes this rule from the current test only. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlinkTestFromRule(rule.RuleID, formData.TestSiteID);
|
||||
toastSuccess('Rule unlinked from test');
|
||||
isDirty = true;
|
||||
loadRules(true);
|
||||
} catch (err) {
|
||||
toastError(err?.message || 'Failed to unlink rule');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800">Rules</h2>
|
||||
<p class="text-sm text-gray-500">Attach DSL-powered rules to this test. One test can have multiple rules.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-ghost">{rulesCount} linked</span>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
onclick={() => openEditor('create')}
|
||||
disabled={!hasTestId || loading}
|
||||
type="button"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
New rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasTestId}
|
||||
<div class="alert alert-info">
|
||||
<AlertTriangle class="w-4 h-4" />
|
||||
Save the test first so we can link rules via `TestSiteID`.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
<button class="underline" onclick={() => loadRules(true)} type="button">Refresh rules</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 border border-base-200 rounded-lg shadow-sm overflow-hidden">
|
||||
{#if loading}
|
||||
<div class="p-6 text-center text-sm text-gray-600">
|
||||
<Loader2 class="w-6 h-6 mx-auto mb-2 animate-spin" />
|
||||
Loading linked rules...
|
||||
</div>
|
||||
{:else if rules.length === 0}
|
||||
<div class="p-6 text-sm text-gray-500 text-center">
|
||||
No rules are linked yet. Create one to run custom logic <br /> when events fire.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-base-200">
|
||||
{#each rules as rule (rule.RuleID)}
|
||||
<div class="flex flex-col gap-2 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-400 uppercase">{rule.EventCode || 'ORDER_CREATED'}</div>
|
||||
<div class="text-sm font-semibold text-gray-800">{rule.RuleCode || 'UNTITLED'}</div>
|
||||
<div class="text-xs text-gray-500">{rule.RuleName || 'Unnamed rule'}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[10px] text-gray-500">
|
||||
<span class="badge badge-xs {rule.ConditionExprCompiled ? 'badge-success' : 'badge-warning'}">
|
||||
{rule.ConditionExprCompiled ? 'Compiled' : 'Needs compile'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 text-xs text-gray-600">
|
||||
<p class="truncate">{rule.ConditionExpr || 'No expression defined'}</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-xs" onclick={() => openEditor('edit', rule)} type="button">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => handleUnlink(rule)} type="button">Unlink</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-base-50 border border-dashed border-base-200 rounded-lg p-4 text-xs text-gray-500">
|
||||
<p class="font-semibold text-gray-600">Rule engine reference</p>
|
||||
<p class="mt-1">Rules run when `test_created` or `result_updated` fire. The DSL uses <code>if(condition; then; else)</code> syntax – see <a href={resolve('/docs/test-rule-engine.md')} class="text-primary underline">docs/test-rule-engine.md</a>.</p>
|
||||
</div>
|
||||
|
||||
<RuleEditorModal
|
||||
bind:open={editorOpen}
|
||||
ruleMode={editorMode}
|
||||
testSiteId={formData.TestSiteID}
|
||||
initialRule={selectedRule}
|
||||
on:saved={handleEditorSaved}
|
||||
on:close={handleEditorClose}
|
||||
/>
|
||||
</div>
|
||||
@ -175,10 +175,7 @@ import OrderSearchBar from './OrderSearchBar.svelte';
|
||||
try {
|
||||
if (orderForm.order) {
|
||||
// Update existing order
|
||||
await updateOrder({
|
||||
OrderID: orderForm.order.OrderID,
|
||||
...formData
|
||||
});
|
||||
await updateOrder(orderForm.order.OrderID, formData);
|
||||
toastSuccess('Order updated successfully');
|
||||
} else {
|
||||
// Create new order
|
||||
|
||||
@ -222,7 +222,7 @@
|
||||
});
|
||||
|
||||
if (isEdit) {
|
||||
await updatePatient(payload);
|
||||
await updatePatient(payload.InternalPID || patient?.InternalPID, payload);
|
||||
toastSuccess('Patient updated successfully');
|
||||
} else {
|
||||
await createPatient(payload);
|
||||
|
||||
@ -220,7 +220,7 @@
|
||||
|
||||
let savedVisit;
|
||||
if (isEdit) {
|
||||
savedVisit = await updateVisit(payload);
|
||||
savedVisit = await updateVisit(payload.InternalPVID, payload);
|
||||
toastSuccess('Visit updated successfully');
|
||||
} else {
|
||||
savedVisit = await createVisit(payload);
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
Calculator,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import { calculateByTestSite } from '$lib/api/calculator.js';
|
||||
import { updateResult } from '$lib/api/results.js';
|
||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -121,11 +122,6 @@
|
||||
console.log('Calc defs:', calcDefs.size, 'calculated tests');
|
||||
console.log('Calc test IDs:', Array.from(calcDefs.keys()));
|
||||
|
||||
// Trigger initial calculation for CALC tests
|
||||
if (calcDefs.size > 0) {
|
||||
console.log('Triggering initial calculation...');
|
||||
setTimeout(() => recalculateAll(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -333,6 +329,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function recalculateSingle(testSiteId) {
|
||||
const def = calcDefs.get(testSiteId);
|
||||
const row = results.find(r => r.TestSiteID === testSiteId);
|
||||
if (!def || !row) return;
|
||||
|
||||
await computeCalculations([{ calcId: testSiteId, def, row }]);
|
||||
await recalculateFrom(testSiteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute multiple calculations via backend API
|
||||
* @param {Array} calcsToCompute - Array of {calcId, def, row}
|
||||
@ -341,59 +346,49 @@
|
||||
calcLoading = true;
|
||||
|
||||
try {
|
||||
// Build batch request
|
||||
const calculations = calcsToCompute.map(({ def }) => {
|
||||
// Collect member values
|
||||
const calculations = calcsToCompute.map(({ def, row }) => {
|
||||
const values = {};
|
||||
let incomplete = false;
|
||||
|
||||
for (const member of def.members) {
|
||||
const rawValue = resultMapById.get(member.id);
|
||||
if (rawValue === '' || rawValue == null) {
|
||||
incomplete = true;
|
||||
break;
|
||||
for (const [code, rawValue] of resultMapByCode) {
|
||||
if (def.code && code === def.code) continue;
|
||||
const numValue = rawValue === '' || rawValue == null ? null : parseFloat(rawValue);
|
||||
if (Number.isFinite(numValue)) {
|
||||
values[code] = numValue;
|
||||
}
|
||||
const numValue = parseFloat(rawValue);
|
||||
if (!Number.isFinite(numValue)) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
values[member.code] = numValue;
|
||||
}
|
||||
|
||||
return {
|
||||
testSiteId: def.id,
|
||||
row,
|
||||
formula: def.formula,
|
||||
values,
|
||||
decimal: def.decimal,
|
||||
incomplete
|
||||
decimal: def.decimal
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out incomplete calculations
|
||||
const validCalculations = calculations.filter(c => !c.incomplete);
|
||||
const calcOutputs = await Promise.all(calculations.map(async calc => {
|
||||
try {
|
||||
const response = await calculateByTestSite(calc.testSiteId, calc.values);
|
||||
const apiResult = Number(response?.data?.result);
|
||||
|
||||
if (validCalculations.length === 0) {
|
||||
// Clear results for incomplete calculations
|
||||
for (const { row } of calcsToCompute) {
|
||||
const index = results.findIndex(r => r.TestSiteID === row.TestSiteID);
|
||||
if (index !== -1) {
|
||||
results[index].Result = '';
|
||||
results[index].changedByAutoCalc = true;
|
||||
results[index].lastAutoCalcAt = Date.now();
|
||||
results[index].warning = 'Missing dependency values';
|
||||
updateResultFlag(index);
|
||||
}
|
||||
}
|
||||
return;
|
||||
if (response?.status !== 'success' || !Number.isFinite(apiResult)) {
|
||||
throw new Error(response?.message || 'Calculator service returned an invalid response');
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend API call
|
||||
// const response = await evaluateCalculations(validCalculations);
|
||||
// For now, compute locally until backend is ready
|
||||
const calcOutputs = computeLocally(validCalculations);
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
row: calc.row,
|
||||
result: apiResult,
|
||||
decimal: calc.decimal
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
row: calc.row,
|
||||
error: { type: 'CALC_API_ERROR', message: err?.message || 'Failed to calculate result' }
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Update results
|
||||
for (const calcResult of calcOutputs) {
|
||||
const index = results.findIndex(r => r.TestSiteID === calcResult.testSiteId);
|
||||
if (index === -1) continue;
|
||||
@ -401,17 +396,22 @@
|
||||
if (calcResult.error) {
|
||||
results[index].warning = calcResult.error.message;
|
||||
} else {
|
||||
results[index].Result = String(calcResult.resultRounded);
|
||||
const factor = Math.pow(10, calcResult.decimal ?? 2);
|
||||
const rounded = Math.round(calcResult.result * factor) / factor;
|
||||
|
||||
results[index].Result = String(rounded);
|
||||
results[index].changedByAutoCalc = true;
|
||||
results[index].lastAutoCalcAt = Date.now();
|
||||
results[index].warning = null;
|
||||
results[index].error = null;
|
||||
updateResultFlag(index);
|
||||
|
||||
// Update lookup maps
|
||||
resultMapById.set(calcResult.testSiteId, results[index].Result);
|
||||
if (results[index].TestSiteCode) {
|
||||
resultMapByCode.set(results[index].TestSiteCode, results[index].Result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calculation error:', err);
|
||||
toastError('Failed to compute calculated values');
|
||||
@ -420,58 +420,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary local computation until backend API is ready
|
||||
* @param {Array} calculations - Calculations to compute
|
||||
* @returns {Array} Computation results
|
||||
*/
|
||||
function computeLocally(calculations) {
|
||||
return calculations.map(calc => {
|
||||
try {
|
||||
// Replace variable names with values (word boundary matching)
|
||||
let expression = calc.formula;
|
||||
for (const [code, value] of Object.entries(calc.values)) {
|
||||
// Use word boundary regex to match exact variable names
|
||||
const regex = new RegExp(`\\b${code}\\b`, 'g');
|
||||
expression = expression.replace(regex, value);
|
||||
}
|
||||
|
||||
// Validate expression characters
|
||||
if (!/^[\d\s+\-*/.()]+$/.test(expression)) {
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
error: { type: 'INVALID_EXPRESSION', message: 'Invalid characters in formula' }
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate (temporary - will be replaced by backend)
|
||||
const result = Function('return ' + expression)();
|
||||
|
||||
if (!Number.isFinite(result)) {
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
error: { type: 'NON_FINITE', message: 'Result is not a valid number' }
|
||||
};
|
||||
}
|
||||
|
||||
// Round to specified decimal places
|
||||
const factor = Math.pow(10, calc.decimal);
|
||||
const resultRounded = Math.round(result * factor) / factor;
|
||||
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
result,
|
||||
resultRounded
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
testSiteId: calc.testSiteId,
|
||||
error: { type: 'EVAL_ERROR', message: err.message || 'Formula evaluation failed' }
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input change - update flag and trigger recalculation of dependent fields
|
||||
*/
|
||||
@ -698,7 +646,7 @@
|
||||
<span class="text-sm font-semibold">Specimen Information</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
{#each order.Specimens as specimen}
|
||||
{#each order.Specimens as specimen, index (specimen.SpecimenID ?? specimen.Barcode ?? index)}
|
||||
<div class="bg-base-200/50 rounded p-2">
|
||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||
<Hash class="w-3 h-3" />
|
||||
@ -808,7 +756,7 @@
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs p-0 min-h-0 h-auto"
|
||||
onclick={() => recalculateFrom(result.TestSiteID)}
|
||||
onclick={() => recalculateSingle(result.TestSiteID)}
|
||||
disabled={calcLoading || formLoading}
|
||||
title="Recalculate"
|
||||
>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fetchRules } from '$lib/api/rules.js';
|
||||
import { fetchRules } from '$lib/api/rule.js';
|
||||
import { error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
||||
|
||||
@ -164,7 +164,7 @@
|
||||
InternalPVID: dischargeModal.visit.InternalPVID,
|
||||
EndDate: dischargeModal.dischargeDate,
|
||||
};
|
||||
await updateVisit(updatePayload);
|
||||
await updateVisit(dischargeModal.visit.InternalPVID, updatePayload);
|
||||
|
||||
// Create A03 ADT record
|
||||
try {
|
||||
|
||||
@ -135,7 +135,8 @@
|
||||
});
|
||||
|
||||
if (isEdit) {
|
||||
await updateADT(payload);
|
||||
const adtId = payload.PVADTID || adt?.PVADTID;
|
||||
await updateADT(adtId, payload);
|
||||
toastSuccess('ADT record updated successfully');
|
||||
} else {
|
||||
await createADT(payload);
|
||||
|
||||
@ -156,7 +156,8 @@
|
||||
|
||||
let savedVisit;
|
||||
if (isEdit) {
|
||||
savedVisit = await updateVisit(payload);
|
||||
const visitId = payload.InternalPVID || visit?.InternalPVID;
|
||||
savedVisit = await updateVisit(visitId, payload);
|
||||
toastSuccess('Visit updated successfully');
|
||||
} else {
|
||||
savedVisit = await createVisit(payload);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user