feat: add two-argument result_set syntax for targeting tests by code

- result_set('CODE', value) now supported alongside legacy result_set(value)
- RuleEngineService: add resolveTestSiteIdByCode() helper
- RuleExpressionService: add splitTopLevel() and unquoteStringArgument() helpers
- Update docs/test-rule-engine.md with new syntax examples
- Delete completed issue from issues.md
This commit is contained in:
mahdahar 2026-03-16 16:39:54 +07:00
parent aaadd593dd
commit 4bb5496073
5 changed files with 382 additions and 10 deletions

View File

@ -181,6 +181,15 @@ class RuleEngineService
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null; $testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
} }
$testCode = $action['testCode'] ?? null;
if ($testCode !== null) {
$resolvedId = $this->resolveTestSiteIdByCode($testCode);
if ($resolvedId === null) {
throw new \Exception('SET_RESULT unknown test code: ' . $testCode);
}
$testSiteID = $resolvedId;
}
if ($testSiteID === null) { if ($testSiteID === null) {
throw new \Exception('SET_RESULT requires testSiteID'); throw new \Exception('SET_RESULT requires testSiteID');
} }
@ -192,6 +201,8 @@ class RuleEngineService
$value = $action['value'] ?? null; $value = $action['value'] ?? null;
} }
$testSiteCode = $testCode ?? $this->resolveTestSiteCode($testSiteID);
$db = \Config\Database::connect(); $db = \Config\Database::connect();
// Check if patres row exists // Check if patres row exists
@ -214,7 +225,7 @@ class RuleEngineService
$ok = $db->table('patres')->insert([ $ok = $db->table('patres')->insert([
'OrderID' => $internalOID, 'OrderID' => $internalOID,
'TestSiteID' => $testSiteID, 'TestSiteID' => $testSiteID,
'TestSiteCode' => $this->resolveTestSiteCode($testSiteID), 'TestSiteCode' => $testSiteCode,
'Result' => $value, 'Result' => $value,
'CreateDate' => date('Y-m-d H:i:s'), 'CreateDate' => date('Y-m-d H:i:s'),
]); ]);
@ -360,4 +371,19 @@ class RuleEngineService
return null; return null;
} }
} }
private function resolveTestSiteIdByCode(string $testSiteCode): ?int
{
try {
$testDefSiteModel = new TestDefSiteModel();
$row = $testDefSiteModel->where('TestSiteCode', $testSiteCode)->where('EndDate', null)->first();
if (empty($row['TestSiteID'])) {
return null;
}
return (int) $row['TestSiteID'];
} catch (\Throwable $e) {
return null;
}
}
} }

View File

@ -53,7 +53,8 @@ class RuleExpressionService
* - priority('R'|'S'|'U') -> order["Priority"] == 'S' * - priority('R'|'S'|'U') -> order["Priority"] == 'S'
* - age > 18 -> age > 18 * - age > 18 -> age > 18
* - requested('CODE') -> requested('CODE') (resolved at runtime) * - requested('CODE') -> requested('CODE') (resolved at runtime)
* - result_set(value) * - result_set('TestSiteCode', value)
* - result_set(value) (deprecated, uses current context TestSiteID)
* - test_insert('CODE') * - test_insert('CODE')
* - test_delete('CODE') * - test_delete('CODE')
* - comment_insert('text') * - comment_insert('text')
@ -242,9 +243,22 @@ class RuleExpressionService
]; ];
} }
// result_set(value) [aliases: set_result] // result_set(value) or result_set('CODE', value) [aliases: set_result]
if (preg_match('/^(result_set|set_result)\s*\(\s*(.+?)\s*\)$/is', $action, $m)) { if (preg_match('/^(result_set|set_result)\s*\(\s*(.+)\s*\)$/is', $action, $m)) {
$value = trim($m[2]); $args = $this->splitTopLevel($m[2], ',', 2);
if (empty($args)) {
throw new \InvalidArgumentException('result_set requires a value expression');
}
$testCode = null;
if (count($args) === 2) {
$testCode = $this->unquoteStringArgument($args[0]);
if ($testCode === null) {
throw new \InvalidArgumentException('result_set test code must be a quoted string');
}
}
$value = trim($args[count($args) - 1]);
// Check if it's a number // Check if it's a number
if (is_numeric($value)) { if (is_numeric($value)) {
@ -252,6 +266,7 @@ class RuleExpressionService
'type' => 'RESULT_SET', 'type' => 'RESULT_SET',
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value, 'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
'valueExpr' => $value, 'valueExpr' => $value,
'testCode' => $testCode,
]; ];
} }
@ -261,6 +276,7 @@ class RuleExpressionService
'type' => 'RESULT_SET', 'type' => 'RESULT_SET',
'value' => $vm[1], 'value' => $vm[1],
'valueExpr' => '"' . addslashes($vm[1]) . '"', 'valueExpr' => '"' . addslashes($vm[1]) . '"',
'testCode' => $testCode,
]; ];
} }
@ -268,6 +284,7 @@ class RuleExpressionService
return [ return [
'type' => 'RESULT_SET', 'type' => 'RESULT_SET',
'valueExpr' => $value, 'valueExpr' => $value,
'testCode' => $testCode,
]; ];
} }
@ -666,4 +683,24 @@ class RuleExpressionService
'endIndex' => $start + 2, 'endIndex' => $start + 2,
]; ];
} }
private function unquoteStringArgument(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$quote = $value[0];
if ($quote !== '"' && $quote !== "'") {
return null;
}
if (substr($value, -1) !== $quote) {
return null;
}
$content = substr($value, 1, -1);
return stripcslashes($content);
}
} }

View File

@ -0,0 +1,309 @@
# Calculator Service Operators Reference
## Overview
The `CalculatorService` (`app/Services/CalculatorService.php`) uses the [mossadal/math-parser](https://github.com/mossadal/math-parser) library to safely evaluate mathematical expressions. This document lists all available operators, functions, and constants.
---
## Supported Operators
### Arithmetic Operators
| Operator | Description | Example | Result |
|----------|-------------|---------|--------|
| `+` | Addition | `5 + 3` | `8` |
| `-` | Subtraction | `10 - 4` | `6` |
| `*` | Multiplication | `6 * 7` | `42` |
| `/` | Division | `20 / 4` | `5` |
| `^` | Exponentiation (power) | `2 ^ 3` | `8` |
| `!` | Factorial | `5!` | `120` |
| `!!` | Semi-factorial (double factorial) | `5!!` | `15` |
### Parentheses
Use parentheses to control operation precedence:
```
(2 + 3) * 4 // Result: 20
2 + 3 * 4 // Result: 14
```
---
## Mathematical Functions
### Rounding Functions
| Function | Description | Example |
|----------|-------------|---------|
| `sqrt(x)` | Square root | `sqrt(16)``4` |
| `round(x)` | Round to nearest integer | `round(3.7)``4` |
| `ceil(x)` | Round up to integer | `ceil(3.2)``4` |
| `floor(x)` | Round down to integer | `floor(3.9)``3` |
| `abs(x)` | Absolute value | `abs(-5)``5` |
| `sgn(x)` | Sign function | `sgn(-10)``-1` |
### Trigonometric Functions (Radians)
| Function | Description | Example |
|----------|-------------|---------|
| `sin(x)` | Sine | `sin(pi/2)``1` |
| `cos(x)` | Cosine | `cos(0)``1` |
| `tan(x)` | Tangent | `tan(pi/4)``1` |
| `cot(x)` | Cotangent | `cot(pi/4)``1` |
### Trigonometric Functions (Degrees)
| Function | Description | Example |
|----------|-------------|---------|
| `sind(x)` | Sine (degrees) | `sind(90)``1` |
| `cosd(x)` | Cosine (degrees) | `cosd(0)``1` |
| `tand(x)` | Tangent (degrees) | `tand(45)``1` |
| `cotd(x)` | Cotangent (degrees) | `cotd(45)``1` |
### Hyperbolic Functions
| Function | Description | Example |
|----------|-------------|---------|
| `sinh(x)` | Hyperbolic sine | `sinh(1)``1.175...` |
| `cosh(x)` | Hyperbolic cosine | `cosh(1)``1.543...` |
| `tanh(x)` | Hyperbolic tangent | `tanh(1)``0.761...` |
| `coth(x)` | Hyperbolic cotangent | `coth(2)``1.037...` |
### Inverse Trigonometric Functions
| Function | Aliases | Description | Example |
|----------|---------|-------------|---------|
| `arcsin(x)` | `asin(x)` | Inverse sine | `arcsin(0.5)``0.523...` |
| `arccos(x)` | `acos(x)` | Inverse cosine | `arccos(0.5)``1.047...` |
| `arctan(x)` | `atan(x)` | Inverse tangent | `arctan(1)``0.785...` |
| `arccot(x)` | `acot(x)` | Inverse cotangent | `arccot(1)``0.785...` |
### Inverse Hyperbolic Functions
| Function | Aliases | Description | Example |
|----------|---------|-------------|---------|
| `arsinh(x)` | `asinh(x)`, `arcsinh(x)` | Inverse hyperbolic sine | `arsinh(1)``0.881...` |
| `arcosh(x)` | `acosh(x)`, `arccosh(x)` | Inverse hyperbolic cosine | `arcosh(2)``1.316...` |
| `artanh(x)` | `atanh(x)`, `arctanh(x)` | Inverse hyperbolic tangent | `artanh(0.5)``0.549...` |
| `arcoth(x)` | `acoth(x)`, `arccoth(x)` | Inverse hyperbolic cotangent | `arcoth(2)``0.549...` |
### Logarithmic & Exponential Functions
| Function | Aliases | Description | Example |
|----------|---------|-------------|---------|
| `exp(x)` | - | Exponential (e^x) | `exp(2)``7.389...` |
| `log(x)` | `ln(x)` | Natural logarithm (base e) | `log(e)``1` |
| `log10(x)` | `lg(x)` | Logarithm base 10 | `log10(100)``2` |
---
## Constants
| Constant | Value | Description | Example |
|----------|-------|-------------|---------|
| `pi` | 3.14159265... | Ratio of circle circumference to diameter | `pi * r ^ 2` |
| `e` | 2.71828182... | Euler's number | `e ^ x` |
| `NAN` | Not a Number | Invalid mathematical result | - |
| `INF` | Infinity | Positive infinity | - |
---
## 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
The parser supports implicit multiplication (no explicit `*` operator needed):
| Expression | Parsed As | Result (x=2, y=3) |
|------------|-----------|-------------------|
| `2x` | `2 * x` | `4` |
| `x sin(x)` | `x * sin(x)` | `1.818...` |
| `2xy` | `2 * x * y` | `12` |
| `x^2y` | `x^2 * y` | `12` |
**Note:** Implicit multiplication has the same precedence as explicit multiplication. `xy^2z` is parsed as `x*y^2*z`, NOT as `x*y^(2*z)`.
---
## Usage Examples
### Basic Calculation
```php
use App\Services\CalculatorService;
$calculator = new CalculatorService();
// Simple arithmetic
$result = $calculator->calculate("5 + 3 * 2");
// Result: 11
// Using functions
$result = $calculator->calculate("sqrt(16) + abs(-5)");
// Result: 9
// Using constants
$result = $calculator->calculate("2 * pi * r", ['r' => 5]);
// Result: 31.415...
```
### 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 with rounding
$formula = "round(sqrt({a} ^ 2 + {b} ^ 2))";
$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
- [math-parser GitHub](https://github.com/mossadal/math-parser)
- [math-parser Documentation](http://mossadal.github.io/math-parser/)
- `app/Services/CalculatorService.php`

View File

@ -90,7 +90,8 @@ Each rule compiles and runs independently when its trigger fires and the test is
| Action | Description | Example | | Action | Description | Example |
|--------|-------------|---------| |--------|-------------|---------|
| `result_set(value)` | Write to `patres.Result` for the current order/test using the provided value | `result_set(0.5)` | | `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_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')` | | `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')` | | `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
@ -102,15 +103,15 @@ Each rule compiles and runs independently when its trigger fires and the test is
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`). 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`. 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`). `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed. 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`. 4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`.
## Examples ## Examples
``` ```
if(sex('M'); result_set(0.5); result_set(0.6)) if(sex('M'); result_set('tesA', 0.5):result_set('tesB', 1.2); result_set('tesA', 0.6):result_set('tesB', 1.0))
``` ```
Returns `0.5` for males, `0.6` otherwise. Sets both `tesA`/`tesB` results together per branch.
``` ```
if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing) if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)

View File

@ -1 +0,0 @@
[ ] account initial must be unique