feat: document rule DSL and result calc flows

This commit is contained in:
mahdahar 2026-03-17 16:50:46 +07:00
parent 134040fcb4
commit 41ebbb7b33
51 changed files with 3464 additions and 1402 deletions

Binary file not shown.

Binary file not shown.

View 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'

Binary file not shown.

2
.serena/.gitignore vendored
View File

@ -1,2 +0,0 @@
/cache
/project.local.yml

View File

@ -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

View File

@ -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`).

View File

@ -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.

View File

@ -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.

View File

@ -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 readonly.
# 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

View File

@ -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

View File

@ -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
View 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
View 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 doesnt 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
View 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);
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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);
} }
/** /**

View File

@ -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) {

View File

@ -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);
} }

View File

@ -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);
} }
/** /**

View File

@ -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) {

View File

@ -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);
} }
/** /**

View File

@ -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}`);
} }

View File

@ -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) {

View File

@ -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);
} }
/** /**

View File

@ -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);
} }
/** /**

View File

@ -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) {

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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);
}
} }
/** /**
@ -333,6 +329,15 @@
} }
} }
async function recalculateSingle(testSiteId) {
const def = calcDefs.get(testSiteId);
const row = results.find(r => r.TestSiteID === testSiteId);
if (!def || !row) return;
await computeCalculations([{ calcId: testSiteId, def, row }]);
await recalculateFrom(testSiteId);
}
/** /**
* Compute multiple calculations via backend API * Compute multiple calculations via backend API
* @param {Array} calcsToCompute - Array of {calcId, def, row} * @param {Array} calcsToCompute - Array of {calcId, def, row}
@ -341,59 +346,49 @@
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);
const apiResult = Number(response?.data?.result);
if (validCalculations.length === 0) { if (response?.status !== 'success' || !Number.isFinite(apiResult)) {
// Clear results for incomplete calculations throw new Error(response?.message || 'Calculator service returned an invalid response');
for (const { row } of calcsToCompute) {
const index = results.findIndex(r => r.TestSiteID === row.TestSiteID);
if (index !== -1) {
results[index].Result = '';
results[index].changedByAutoCalc = true;
results[index].lastAutoCalcAt = Date.now();
results[index].warning = 'Missing dependency values';
updateResultFlag(index);
} }
return {
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;
@ -401,15 +396,20 @@
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) {
@ -420,58 +420,6 @@
} }
} }
/**
* Temporary local computation until backend API is ready
* @param {Array} calculations - Calculations to compute
* @returns {Array} Computation results
*/
function computeLocally(calculations) {
return calculations.map(calc => {
try {
// Replace variable names with values (word boundary matching)
let expression = calc.formula;
for (const [code, value] of Object.entries(calc.values)) {
// Use word boundary regex to match exact variable names
const regex = new RegExp(`\\b${code}\\b`, 'g');
expression = expression.replace(regex, value);
}
// Validate expression characters
if (!/^[\d\s+\-*/.()]+$/.test(expression)) {
return {
testSiteId: calc.testSiteId,
error: { type: 'INVALID_EXPRESSION', message: 'Invalid characters in formula' }
};
}
// Evaluate (temporary - will be replaced by backend)
const result = Function('return ' + expression)();
if (!Number.isFinite(result)) {
return {
testSiteId: calc.testSiteId,
error: { type: 'NON_FINITE', message: 'Result is not a valid number' }
};
}
// Round to specified decimal places
const factor = Math.pow(10, calc.decimal);
const resultRounded = Math.round(result * factor) / factor;
return {
testSiteId: calc.testSiteId,
result,
resultRounded
};
} catch (err) {
return {
testSiteId: calc.testSiteId,
error: { type: 'EVAL_ERROR', message: err.message || 'Formula evaluation failed' }
};
}
});
}
/** /**
* Handle input change - update flag and trigger recalculation of dependent fields * 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"
> >

View File

@ -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';

View File

@ -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 {

View File

@ -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);

View File

@ -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);