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:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: List rules
|
summary: List rules
|
||||||
@ -100,7 +100,7 @@
|
|||||||
'201':
|
'201':
|
||||||
description: Rule created
|
description: Rule created
|
||||||
|
|
||||||
/api/rules/{id}:
|
/api/rule/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Get rule (with actions)
|
summary: Get rule (with actions)
|
||||||
@ -178,7 +178,7 @@
|
|||||||
'404':
|
'404':
|
||||||
description: Rule not found
|
description: Rule not found
|
||||||
|
|
||||||
/api/rules/validate:
|
/api/rule/validate:
|
||||||
post:
|
post:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Validate/evaluate an expression
|
summary: Validate/evaluate an expression
|
||||||
@ -201,7 +201,7 @@
|
|||||||
'200':
|
'200':
|
||||||
description: Validation result
|
description: Validation result
|
||||||
|
|
||||||
/api/rules/{id}/actions:
|
/api/rule/{id}/actions:
|
||||||
get:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: List actions for a rule
|
summary: List actions for a rule
|
||||||
@ -261,7 +261,7 @@
|
|||||||
'201':
|
'201':
|
||||||
description: Action created
|
description: Action created
|
||||||
|
|
||||||
/api/rules/{id}/actions/{actionId}:
|
/api/rule/{id}/actions/{actionId}:
|
||||||
patch:
|
patch:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Update action
|
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);
|
return post('/api/contact', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContact(data) {
|
export async function updateContact(id, data) {
|
||||||
return patch('/api/contact', data);
|
return patch(`/api/contact/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContact(id) {
|
export async function deleteContact(id) {
|
||||||
|
|||||||
@ -21,9 +21,8 @@ export async function createContainer(data) {
|
|||||||
return post('/api/specimen/container', payload);
|
return post('/api/specimen/container', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContainer(data) {
|
export async function updateContainer(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
ConDefID: data.ConDefID,
|
|
||||||
ConCode: data.ConCode,
|
ConCode: data.ConCode,
|
||||||
ConName: data.ConName,
|
ConName: data.ConName,
|
||||||
ConDesc: data.ConDesc,
|
ConDesc: data.ConDesc,
|
||||||
@ -31,7 +30,7 @@ export async function updateContainer(data) {
|
|||||||
Additive: data.Additive,
|
Additive: data.Additive,
|
||||||
Color: data.Color,
|
Color: data.Color,
|
||||||
};
|
};
|
||||||
return patch('/api/specimen/container', payload);
|
return patch(`/api/specimen/container/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContainer(id) {
|
export async function deleteContainer(id) {
|
||||||
|
|||||||
@ -13,8 +13,8 @@ export async function createCounter(data) {
|
|||||||
return post('/api/counter', data);
|
return post('/api/counter', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCounter(data) {
|
export async function updateCounter(id, data) {
|
||||||
return patch('/api/counter', data);
|
return patch(`/api/counter/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCounter(id) {
|
export async function deleteCounter(id) {
|
||||||
|
|||||||
@ -62,9 +62,8 @@ export async function createEquipment(data) {
|
|||||||
* @param {number} [data.WorkstationID] - Workstation ID
|
* @param {number} [data.WorkstationID] - Workstation ID
|
||||||
* @returns {Promise<Object>} Updated equipment ID
|
* @returns {Promise<Object>} Updated equipment ID
|
||||||
*/
|
*/
|
||||||
export async function updateEquipment(data) {
|
export async function updateEquipment(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
EID: data.EID,
|
|
||||||
IEID: data.IEID,
|
IEID: data.IEID,
|
||||||
DepartmentID: data.DepartmentID,
|
DepartmentID: data.DepartmentID,
|
||||||
Enable: data.Enable,
|
Enable: data.Enable,
|
||||||
@ -73,7 +72,7 @@ export async function updateEquipment(data) {
|
|||||||
InstrumentName: data.InstrumentName || null,
|
InstrumentName: data.InstrumentName || null,
|
||||||
WorkstationID: data.WorkstationID || 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);
|
return post('/api/location', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLocation(data) {
|
export async function updateLocation(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
LocationID: data.LocationID,
|
|
||||||
LocCode: data.Code,
|
LocCode: data.Code,
|
||||||
LocFull: data.Name,
|
LocFull: data.Name,
|
||||||
LocType: data.Type,
|
LocType: data.Type,
|
||||||
Parent: data.ParentID,
|
Parent: data.ParentID,
|
||||||
};
|
};
|
||||||
return patch('/api/location', payload);
|
return patch(`/api/location/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteLocation(id) {
|
export async function deleteLocation(id) {
|
||||||
|
|||||||
@ -18,12 +18,11 @@ export async function createOccupation(data) {
|
|||||||
return post('/api/occupation', payload);
|
return post('/api/occupation', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOccupation(data) {
|
export async function updateOccupation(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
OccupationID: data.OccupationID,
|
|
||||||
OccCode: data.OccCode,
|
OccCode: data.OccCode,
|
||||||
OccText: data.OccText,
|
OccText: data.OccText,
|
||||||
Description: data.Description,
|
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
|
* @param {number} [data.WorkstationID] - Workstation ID
|
||||||
* @returns {Promise<Object>} API response with updated order data
|
* @returns {Promise<Object>} API response with updated order data
|
||||||
*/
|
*/
|
||||||
export async function updateOrder(data) {
|
export async function updateOrder(id, data) {
|
||||||
return patch('/api/ordertest', data);
|
return patch(`/api/ordertest/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -21,16 +21,15 @@ export async function createDiscipline(data) {
|
|||||||
return post('/api/organization/discipline', payload);
|
return post('/api/organization/discipline', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDiscipline(data) {
|
export async function updateDiscipline(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.DisciplineID,
|
|
||||||
DisciplineCode: data.DisciplineCode,
|
DisciplineCode: data.DisciplineCode,
|
||||||
DisciplineName: data.DisciplineName,
|
DisciplineName: data.DisciplineName,
|
||||||
Parent: data.Parent || null,
|
Parent: data.Parent || null,
|
||||||
SeqScr: data.SeqScr,
|
SeqScr: data.SeqScr,
|
||||||
SeqRpt: data.SeqRpt,
|
SeqRpt: data.SeqRpt,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/discipline', payload);
|
return patch(`/api/organization/discipline/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDiscipline(id) {
|
export async function deleteDiscipline(id) {
|
||||||
@ -56,14 +55,13 @@ export async function createDepartment(data) {
|
|||||||
return post('/api/organization/department', payload);
|
return post('/api/organization/department', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDepartment(data) {
|
export async function updateDepartment(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.DepartmentID,
|
|
||||||
DeptCode: data.DeptCode,
|
DeptCode: data.DeptCode,
|
||||||
DeptName: data.DeptName,
|
DeptName: data.DeptName,
|
||||||
SiteID: data.SiteID,
|
SiteID: data.SiteID,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/department', payload);
|
return patch(`/api/organization/department/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDepartment(id) {
|
export async function deleteDepartment(id) {
|
||||||
@ -89,14 +87,13 @@ export async function createSite(data) {
|
|||||||
return post('/api/organization/site', payload);
|
return post('/api/organization/site', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSite(data) {
|
export async function updateSite(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.SiteID,
|
|
||||||
SiteCode: data.SiteCode,
|
SiteCode: data.SiteCode,
|
||||||
SiteName: data.SiteName,
|
SiteName: data.SiteName,
|
||||||
AccountID: data.AccountID,
|
AccountID: data.AccountID,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/site', payload);
|
return patch(`/api/organization/site/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSite(id) {
|
export async function deleteSite(id) {
|
||||||
@ -131,13 +128,12 @@ export async function createHostApp(data) {
|
|||||||
return post('/api/organization/hostapp', payload);
|
return post('/api/organization/hostapp', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHostApp(data) {
|
export async function updateHostApp(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.HostAppID,
|
|
||||||
HostAppName: data.HostAppName,
|
HostAppName: data.HostAppName,
|
||||||
SiteID: data.SiteID,
|
SiteID: data.SiteID,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/hostapp', payload);
|
return patch(`/api/organization/hostapp/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHostApp(id) {
|
export async function deleteHostApp(id) {
|
||||||
@ -164,15 +160,14 @@ export async function createHostComPara(data) {
|
|||||||
return post('/api/organization/hostcompara', payload);
|
return post('/api/organization/hostcompara', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHostComPara(data) {
|
export async function updateHostComPara(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.HostComParaID,
|
|
||||||
HostAppID: data.HostAppID,
|
HostAppID: data.HostAppID,
|
||||||
HostIP: data.HostIP,
|
HostIP: data.HostIP,
|
||||||
HostPort: data.HostPort,
|
HostPort: data.HostPort,
|
||||||
HostPwd: data.HostPwd,
|
HostPwd: data.HostPwd,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/hostcompara', payload);
|
return patch(`/api/organization/hostcompara/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHostComPara(id) {
|
export async function deleteHostComPara(id) {
|
||||||
@ -198,14 +193,13 @@ export async function createCodingSystem(data) {
|
|||||||
return post('/api/organization/codingsys', payload);
|
return post('/api/organization/codingsys', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCodingSystem(data) {
|
export async function updateCodingSystem(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.CodingSysID,
|
|
||||||
CodingSysAbb: data.CodingSysAbb,
|
CodingSysAbb: data.CodingSysAbb,
|
||||||
FullText: data.FullText,
|
FullText: data.FullText,
|
||||||
Description: data.Description,
|
Description: data.Description,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/codingsys', payload);
|
return patch(`/api/organization/codingsys/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCodingSystem(id) {
|
export async function deleteCodingSystem(id) {
|
||||||
@ -232,14 +226,13 @@ export async function createAccount(data) {
|
|||||||
return post('/api/organization/account', payload);
|
return post('/api/organization/account', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccount(data) {
|
export async function updateAccount(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.AccountID,
|
|
||||||
AccountName: data.AccountName,
|
AccountName: data.AccountName,
|
||||||
Initial: data.Initial,
|
Initial: data.Initial,
|
||||||
Parent: data.Parent,
|
Parent: data.Parent,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/account', payload);
|
return patch(`/api/organization/account/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAccount(id) {
|
export async function deleteAccount(id) {
|
||||||
@ -257,15 +250,14 @@ export async function createWorkstation(data) {
|
|||||||
return post('/api/organization/workstation', payload);
|
return post('/api/organization/workstation', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkstation(data) {
|
export async function updateWorkstation(id, data) {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: data.WorkstationID,
|
|
||||||
WorkstationCode: data.WorkstationCode,
|
WorkstationCode: data.WorkstationCode,
|
||||||
WorkstationName: data.WorkstationName,
|
WorkstationName: data.WorkstationName,
|
||||||
SiteID: data.SiteID,
|
SiteID: data.SiteID,
|
||||||
DepartmentID: data.DepartmentID,
|
DepartmentID: data.DepartmentID,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/workstation', payload);
|
return patch(`/api/organization/workstation/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWorkstation(id) {
|
export async function deleteWorkstation(id) {
|
||||||
|
|||||||
@ -84,8 +84,8 @@ export async function createPatient(data) {
|
|||||||
* @param {Object} data - Patient data (must include PatientID)
|
* @param {Object} data - Patient data (must include PatientID)
|
||||||
* @returns {Promise<Object>} API response
|
* @returns {Promise<Object>} API response
|
||||||
*/
|
*/
|
||||||
export async function updatePatient(data) {
|
export async function updatePatient(id, data) {
|
||||||
return patch('/api/patient', data);
|
return patch(`/api/patient/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { get, post, patch, del } from './client.js';
|
|||||||
*/
|
*/
|
||||||
export async function fetchRules(params = {}) {
|
export async function fetchRules(params = {}) {
|
||||||
const query = new URLSearchParams(params).toString();
|
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>}
|
* @returns {Promise<RuleDetailResponse>}
|
||||||
*/
|
*/
|
||||||
export async function fetchRule(id) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function createRule(payload) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function updateRule(id, payload) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function deleteRule(id) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function linkTestToRule(ruleId, testSiteId) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function unlinkTestFromRule(ruleId, testSiteId) {
|
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>}
|
* @returns {Promise<ValidateExprResponse>}
|
||||||
*/
|
*/
|
||||||
export async function validateExpression(expr, context = {}) {
|
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 }>}
|
* @returns {Promise<{ compiled: any; conditionExprCompiled: string }>}
|
||||||
*/
|
*/
|
||||||
export async function compileRuleExpr(expr) {
|
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>}
|
* @returns {Promise<RuleActionsListResponse>}
|
||||||
*/
|
*/
|
||||||
export async function fetchRuleActions(id) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function createRuleAction(id, payload) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function updateRuleAction(id, actionId, payload) {
|
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>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function deleteRuleAction(id, actionId) {
|
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);
|
return post('/api/medicalspecialty', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSpecialty(data) {
|
export async function updateSpecialty(id, data) {
|
||||||
return patch('/api/medicalspecialty', data);
|
return patch(`/api/medicalspecialty/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSpecialty(id) {
|
export async function deleteSpecialty(id) {
|
||||||
|
|||||||
@ -150,8 +150,8 @@ export async function createTestMap(data) {
|
|||||||
* @param {UpdateTestMapPayload} data - Header data
|
* @param {UpdateTestMapPayload} data - Header data
|
||||||
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response
|
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response
|
||||||
*/
|
*/
|
||||||
export async function updateTestMap(data) {
|
export async function updateTestMap(id, data) {
|
||||||
return patch('/api/test/testmap', data);
|
return patch(`/api/test/testmap/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,8 +210,8 @@ export async function createTestMapDetail(data) {
|
|||||||
* @param {UpdateTestMapDetailPayload} data - Detail data
|
* @param {UpdateTestMapDetailPayload} data - Detail data
|
||||||
* @returns {Promise<{success: boolean, message?: string}>} API response
|
* @returns {Promise<{success: boolean, message?: string}>} API response
|
||||||
*/
|
*/
|
||||||
export async function updateTestMapDetail(data) {
|
export async function updateTestMapDetail(id, data) {
|
||||||
return patch('/api/test/testmap/detail', data);
|
return patch(`/api/test/testmap/detail/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -177,9 +177,9 @@ export async function createTest(formData) {
|
|||||||
* @param {any} formData - The form state
|
* @param {any} formData - The form state
|
||||||
* @returns {Promise<UpdateTestResponse>} API response
|
* @returns {Promise<UpdateTestResponse>} API response
|
||||||
*/
|
*/
|
||||||
export async function updateTest(formData) {
|
export async function updateTest(id, formData) {
|
||||||
const payload = buildPayload(formData, true);
|
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);
|
return post('/api/patvisit', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateVisit(data) {
|
export async function updateVisit(id, data) {
|
||||||
return patch('/api/patvisit', data);
|
return patch(`/api/patvisit/${encodeURIComponent(id)}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteVisit(id) {
|
export async function deleteVisit(id) {
|
||||||
@ -29,8 +29,8 @@ export async function createADT(data) {
|
|||||||
return post('/api/patvisitadt', data);
|
return post('/api/patvisitadt', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateADT(data) {
|
export async function updateADT(id, data) {
|
||||||
return patch('/api/patvisitadt', data);
|
return patch(`/api/patvisitadt/${encodeURIComponent(id)}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVisitADTHistory(visitId) {
|
export async function fetchVisitADTHistory(visitId) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
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 { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||||
import { ArrowLeft, Save, Loader2, AlertCircle, CheckCircle2, RefreshCw, Code } from 'lucide-svelte';
|
import { ArrowLeft, Save, Loader2, AlertCircle, CheckCircle2, RefreshCw, Code } from 'lucide-svelte';
|
||||||
|
|
||||||
|
|||||||
@ -125,7 +125,7 @@
|
|||||||
await createContact(dataToSave);
|
await createContact(dataToSave);
|
||||||
toastSuccess('Contact created successfully');
|
toastSuccess('Contact created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateContact(dataToSave);
|
await updateContact(dataToSave.ContactID, dataToSave);
|
||||||
toastSuccess('Contact updated successfully');
|
toastSuccess('Contact updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -113,7 +113,7 @@
|
|||||||
await createContainer(formData);
|
await createContainer(formData);
|
||||||
toastSuccess('Container created successfully');
|
toastSuccess('Container created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateContainer(formData);
|
await updateContainer(formData.ConDefID, formData);
|
||||||
toastSuccess('Container updated successfully');
|
toastSuccess('Container updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -161,7 +161,7 @@
|
|||||||
await createCounter(formData);
|
await createCounter(formData);
|
||||||
toastSuccess('Counter created successfully');
|
toastSuccess('Counter created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateCounter(formData);
|
await updateCounter(formData.CounterID, formData);
|
||||||
toastSuccess('Counter updated successfully');
|
toastSuccess('Counter updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -120,7 +120,7 @@
|
|||||||
await createLocation(formData);
|
await createLocation(formData);
|
||||||
toastSuccess('Location created successfully');
|
toastSuccess('Location created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateLocation(formData);
|
await updateLocation(formData.LocationID, formData);
|
||||||
toastSuccess('Location updated successfully');
|
toastSuccess('Location updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -134,7 +134,7 @@
|
|||||||
await createOccupation(formData);
|
await createOccupation(formData);
|
||||||
toastSuccess('Occupation created successfully');
|
toastSuccess('Occupation created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateOccupation(formData);
|
await updateOccupation(formData.OccupationID, formData);
|
||||||
toastSuccess('Occupation updated successfully');
|
toastSuccess('Occupation updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -136,7 +136,7 @@
|
|||||||
await createEquipment(formData);
|
await createEquipment(formData);
|
||||||
toastSuccess('Equipment created successfully');
|
toastSuccess('Equipment created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateEquipment(formData);
|
await updateEquipment(formData.EID, formData);
|
||||||
toastSuccess('Equipment updated successfully');
|
toastSuccess('Equipment updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -130,7 +130,7 @@
|
|||||||
await createWorkstation(formData);
|
await createWorkstation(formData);
|
||||||
toastSuccess('Workstation created successfully');
|
toastSuccess('Workstation created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateWorkstation(formData);
|
await updateWorkstation(formData.WorkstationID, formData);
|
||||||
toastSuccess('Workstation updated successfully');
|
toastSuccess('Workstation updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -95,7 +95,7 @@
|
|||||||
await createSpecialty(formData);
|
await createSpecialty(formData);
|
||||||
toastSuccess('Specialty created successfully');
|
toastSuccess('Specialty created successfully');
|
||||||
} else {
|
} else {
|
||||||
await updateSpecialty(formData);
|
await updateSpecialty(formData.SpecialtyID, formData);
|
||||||
toastSuccess('Specialty updated successfully');
|
toastSuccess('Specialty updated successfully');
|
||||||
}
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
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 { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
|
||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import BasicInfoTab from './tabs/BasicInfoTab.svelte';
|
import BasicInfoTab from './tabs/BasicInfoTab.svelte';
|
||||||
import TechDetailsTab from './tabs/TechDetailsTab.svelte';
|
import TechDetailsTab from './tabs/TechDetailsTab.svelte';
|
||||||
import CalcDetailsTab from './tabs/CalcDetailsTab.svelte';
|
import CalcDetailsTab from './tabs/CalcDetailsTab.svelte';
|
||||||
import GroupMembersTab from './tabs/GroupMembersTab.svelte';
|
import GroupMembersTab from './tabs/GroupMembersTab.svelte';
|
||||||
import MappingsTab from './tabs/MappingsTab.svelte';
|
import MappingsTab from './tabs/MappingsTab.svelte';
|
||||||
import RefNumTab from './tabs/RefNumTab.svelte';
|
import RefNumTab from './tabs/RefNumTab.svelte';
|
||||||
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
||||||
import ThresholdTab from './tabs/ThresholdTab.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();
|
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: 'mappings', label: 'Mappings', component: Link },
|
||||||
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
||||||
{ id: 'threshold', label: 'Thresholds', component: AlertTriangle },
|
{ 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(() => {
|
const visibleTabs = $derived.by(() => {
|
||||||
@ -61,6 +63,9 @@ import ThresholdTab from './tabs/ThresholdTab.svelte';
|
|||||||
// Show for TEST/PARAM with TEXT result type
|
// Show for TEST/PARAM with TEXT result type
|
||||||
return ['TEST', 'PARAM'].includes(type) && resultType === 'TEXT' && refType === 'TEXT';
|
return ['TEST', 'PARAM'].includes(type) && resultType === 'TEXT' && refType === 'TEXT';
|
||||||
}
|
}
|
||||||
|
if (tab.id === 'rules') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -437,6 +442,12 @@ import ThresholdTab from './tabs/ThresholdTab.svelte';
|
|||||||
bind:formData
|
bind:formData
|
||||||
bind:isDirty
|
bind:isDirty
|
||||||
/>
|
/>
|
||||||
|
{:else if currentTab === 'rules'}
|
||||||
|
<RulesTab
|
||||||
|
bind:formData
|
||||||
|
bind:isDirty
|
||||||
|
{mode}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Formula Code -->
|
||||||
<div class="space-y-2">
|
<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 {
|
try {
|
||||||
if (orderForm.order) {
|
if (orderForm.order) {
|
||||||
// Update existing order
|
// Update existing order
|
||||||
await updateOrder({
|
await updateOrder(orderForm.order.OrderID, formData);
|
||||||
OrderID: orderForm.order.OrderID,
|
|
||||||
...formData
|
|
||||||
});
|
|
||||||
toastSuccess('Order updated successfully');
|
toastSuccess('Order updated successfully');
|
||||||
} else {
|
} else {
|
||||||
// Create new order
|
// Create new order
|
||||||
|
|||||||
@ -222,7 +222,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await updatePatient(payload);
|
await updatePatient(payload.InternalPID || patient?.InternalPID, payload);
|
||||||
toastSuccess('Patient updated successfully');
|
toastSuccess('Patient updated successfully');
|
||||||
} else {
|
} else {
|
||||||
await createPatient(payload);
|
await createPatient(payload);
|
||||||
|
|||||||
@ -220,7 +220,7 @@
|
|||||||
|
|
||||||
let savedVisit;
|
let savedVisit;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
savedVisit = await updateVisit(payload);
|
savedVisit = await updateVisit(payload.InternalPVID, payload);
|
||||||
toastSuccess('Visit updated successfully');
|
toastSuccess('Visit updated successfully');
|
||||||
} else {
|
} else {
|
||||||
savedVisit = await createVisit(payload);
|
savedVisit = await createVisit(payload);
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
Calculator,
|
Calculator,
|
||||||
RefreshCw
|
RefreshCw
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
import { calculateByTestSite } from '$lib/api/calculator.js';
|
||||||
import { updateResult } from '$lib/api/results.js';
|
import { updateResult } from '$lib/api/results.js';
|
||||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
@ -121,11 +122,6 @@
|
|||||||
console.log('Calc defs:', calcDefs.size, 'calculated tests');
|
console.log('Calc defs:', calcDefs.size, 'calculated tests');
|
||||||
console.log('Calc test IDs:', Array.from(calcDefs.keys()));
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -332,6 +328,15 @@
|
|||||||
await computeCalculations(calcsToCompute);
|
await computeCalculations(calcsToCompute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Compute multiple calculations via backend API
|
||||||
@ -339,77 +344,72 @@
|
|||||||
*/
|
*/
|
||||||
async function computeCalculations(calcsToCompute) {
|
async function computeCalculations(calcsToCompute) {
|
||||||
calcLoading = true;
|
calcLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build batch request
|
const calculations = calcsToCompute.map(({ def, row }) => {
|
||||||
const calculations = calcsToCompute.map(({ def }) => {
|
|
||||||
// Collect member values
|
|
||||||
const values = {};
|
const values = {};
|
||||||
let incomplete = false;
|
for (const [code, rawValue] of resultMapByCode) {
|
||||||
|
if (def.code && code === def.code) continue;
|
||||||
for (const member of def.members) {
|
const numValue = rawValue === '' || rawValue == null ? null : parseFloat(rawValue);
|
||||||
const rawValue = resultMapById.get(member.id);
|
if (Number.isFinite(numValue)) {
|
||||||
if (rawValue === '' || rawValue == null) {
|
values[code] = numValue;
|
||||||
incomplete = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
const numValue = parseFloat(rawValue);
|
|
||||||
if (!Number.isFinite(numValue)) {
|
|
||||||
incomplete = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
values[member.code] = numValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
testSiteId: def.id,
|
testSiteId: def.id,
|
||||||
|
row,
|
||||||
formula: def.formula,
|
formula: def.formula,
|
||||||
values,
|
values,
|
||||||
decimal: def.decimal,
|
decimal: def.decimal
|
||||||
incomplete
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter out incomplete calculations
|
const calcOutputs = await Promise.all(calculations.map(async calc => {
|
||||||
const validCalculations = calculations.filter(c => !c.incomplete);
|
try {
|
||||||
|
const response = await calculateByTestSite(calc.testSiteId, calc.values);
|
||||||
if (validCalculations.length === 0) {
|
const apiResult = Number(response?.data?.result);
|
||||||
// Clear results for incomplete calculations
|
|
||||||
for (const { row } of calcsToCompute) {
|
if (response?.status !== 'success' || !Number.isFinite(apiResult)) {
|
||||||
const index = results.findIndex(r => r.TestSiteID === row.TestSiteID);
|
throw new Error(response?.message || 'Calculator service returned an invalid response');
|
||||||
if (index !== -1) {
|
|
||||||
results[index].Result = '';
|
|
||||||
results[index].changedByAutoCalc = true;
|
|
||||||
results[index].lastAutoCalcAt = Date.now();
|
|
||||||
results[index].warning = 'Missing dependency values';
|
|
||||||
updateResultFlag(index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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' }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return;
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Replace with actual backend API call
|
|
||||||
// const response = await evaluateCalculations(validCalculations);
|
|
||||||
// For now, compute locally until backend is ready
|
|
||||||
const calcOutputs = computeLocally(validCalculations);
|
|
||||||
|
|
||||||
// Update results
|
|
||||||
for (const calcResult of calcOutputs) {
|
for (const calcResult of calcOutputs) {
|
||||||
const index = results.findIndex(r => r.TestSiteID === calcResult.testSiteId);
|
const index = results.findIndex(r => r.TestSiteID === calcResult.testSiteId);
|
||||||
if (index === -1) continue;
|
if (index === -1) continue;
|
||||||
|
|
||||||
if (calcResult.error) {
|
if (calcResult.error) {
|
||||||
results[index].warning = calcResult.error.message;
|
results[index].warning = calcResult.error.message;
|
||||||
} else {
|
} 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].changedByAutoCalc = true;
|
||||||
results[index].lastAutoCalcAt = Date.now();
|
results[index].lastAutoCalcAt = Date.now();
|
||||||
results[index].warning = null;
|
results[index].warning = null;
|
||||||
|
results[index].error = null;
|
||||||
updateResultFlag(index);
|
updateResultFlag(index);
|
||||||
|
|
||||||
// Update lookup maps
|
|
||||||
resultMapById.set(calcResult.testSiteId, results[index].Result);
|
resultMapById.set(calcResult.testSiteId, results[index].Result);
|
||||||
resultMapByCode.set(results[index].TestSiteCode, results[index].Result);
|
if (results[index].TestSiteCode) {
|
||||||
|
resultMapByCode.set(results[index].TestSiteCode, results[index].Result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -419,58 +419,6 @@
|
|||||||
calcLoading = false;
|
calcLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Handle input change - update flag and trigger recalculation of dependent fields
|
||||||
@ -698,7 +646,7 @@
|
|||||||
<span class="text-sm font-semibold">Specimen Information</span>
|
<span class="text-sm font-semibold">Specimen Information</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
<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="bg-base-200/50 rounded p-2">
|
||||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||||
<Hash class="w-3 h-3" />
|
<Hash class="w-3 h-3" />
|
||||||
@ -808,7 +756,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs p-0 min-h-0 h-auto"
|
class="btn btn-ghost btn-xs p-0 min-h-0 h-auto"
|
||||||
onclick={() => recalculateFrom(result.TestSiteID)}
|
onclick={() => recalculateSingle(result.TestSiteID)}
|
||||||
disabled={calcLoading || formLoading}
|
disabled={calcLoading || formLoading}
|
||||||
title="Recalculate"
|
title="Recalculate"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
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 { error as toastError } from '$lib/utils/toast.js';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
||||||
|
|||||||
@ -164,7 +164,7 @@
|
|||||||
InternalPVID: dischargeModal.visit.InternalPVID,
|
InternalPVID: dischargeModal.visit.InternalPVID,
|
||||||
EndDate: dischargeModal.dischargeDate,
|
EndDate: dischargeModal.dischargeDate,
|
||||||
};
|
};
|
||||||
await updateVisit(updatePayload);
|
await updateVisit(dischargeModal.visit.InternalPVID, updatePayload);
|
||||||
|
|
||||||
// Create A03 ADT record
|
// Create A03 ADT record
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -135,7 +135,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await updateADT(payload);
|
const adtId = payload.PVADTID || adt?.PVADTID;
|
||||||
|
await updateADT(adtId, payload);
|
||||||
toastSuccess('ADT record updated successfully');
|
toastSuccess('ADT record updated successfully');
|
||||||
} else {
|
} else {
|
||||||
await createADT(payload);
|
await createADT(payload);
|
||||||
|
|||||||
@ -156,7 +156,8 @@
|
|||||||
|
|
||||||
let savedVisit;
|
let savedVisit;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
savedVisit = await updateVisit(payload);
|
const visitId = payload.InternalPVID || visit?.InternalPVID;
|
||||||
|
savedVisit = await updateVisit(visitId, payload);
|
||||||
toastSuccess('Visit updated successfully');
|
toastSuccess('Visit updated successfully');
|
||||||
} else {
|
} else {
|
||||||
savedVisit = await createVisit(payload);
|
savedVisit = await createVisit(payload);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user