clqms-be/app/Services/RuleEngineService.php
mahdahar 4bb5496073 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
2026-03-16 16:39:54 +07:00

390 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
class RuleEngineService
{
protected RuleDefModel $ruleDefModel;
protected RuleExpressionService $expr;
public function __construct()
{
$this->ruleDefModel = new RuleDefModel();
$this->expr = new RuleExpressionService();
}
/**
* Run rules for an event.
*
* Expected context keys:
* - order: array (must include InternalOID)
* - patient: array (optional, includes Sex, Age, etc.)
* - tests: array (optional, patres rows)
*
* @param string $eventCode The event that triggered rule execution
* @param array $context Context data for rule evaluation
* @return void
*/
public function run(string $eventCode, array $context = []): void
{
$order = $context['order'] ?? null;
$testSiteID = $context['testSiteID'] ?? null;
if (is_array($order) && isset($order['TestSiteID']) && $testSiteID === null) {
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
}
$rules = $this->ruleDefModel->getActiveByEvent($eventCode, $testSiteID);
if (empty($rules)) {
return;
}
foreach ($rules as $rule) {
$rid = (int) ($rule['RuleID'] ?? 0);
if ($rid === 0) {
continue;
}
try {
// Rules must have compiled expressions
$compiled = null;
if (!empty($rule['ConditionExprCompiled'])) {
$compiled = json_decode($rule['ConditionExprCompiled'], true);
}
if (empty($compiled) || !is_array($compiled)) {
log_message('warning', 'Rule ' . $rid . ' has no compiled expression, skipping');
continue;
}
// Evaluate condition
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
$matches = $this->evaluateCondition($conditionExpr, $context);
if (!$matches) {
// Execute else actions
$actions = $compiled['else'] ?? [];
} else {
// Execute then actions
$actions = $compiled['then'] ?? [];
}
// Execute all actions
foreach ($actions as $action) {
$this->executeAction($action, $context);
}
} catch (\Throwable $e) {
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
continue;
}
}
}
/**
* Evaluate a condition expression
* Handles special functions like requested() by querying the database
*/
private function evaluateCondition(string $conditionExpr, array $context): bool
{
// Handle requested() function(s) by querying database
$conditionExpr = preg_replace_callback(
'/requested\s*\(\s*["\']([^"\']+)["\']\s*\)/i',
function (array $m) use ($context) {
$testCode = $m[1] ?? '';
if ($testCode === '') {
return 'false';
}
return $this->isTestRequested($testCode, $context) ? 'true' : 'false';
},
$conditionExpr
);
return $this->expr->evaluateBoolean($conditionExpr, $context);
}
/**
* Check if a test was requested for the current order
*/
private function isTestRequested(string $testCode, array $context): bool
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
return false;
}
$internalOID = (int) $order['InternalOID'];
$db = \Config\Database::connect();
// Query patres to check if test with given code exists
$result = $db->table('patres')
->select('patres.*, testdefsite.TestSiteCode')
->join('testdefsite', 'testdefsite.TestSiteID = patres.TestSiteID', 'inner')
->where('patres.OrderID', $internalOID)
->where('testdefsite.TestSiteCode', $testCode)
->where('patres.DelDate', null)
->where('testdefsite.EndDate', null)
->get()
->getRow();
return $result !== null;
}
/**
* Execute an action based on its type
*/
protected function executeAction(array $action, array $context): void
{
$type = strtoupper((string) ($action['type'] ?? ''));
switch ($type) {
case 'RESULT_SET':
case 'SET_RESULT': // legacy
$this->executeSetResult($action, $context);
break;
case 'TEST_INSERT':
case 'INSERT_TEST': // legacy
$this->executeInsertTest($action, $context);
break;
case 'TEST_DELETE':
$this->executeDeleteTest($action, $context);
break;
case 'COMMENT_INSERT':
case 'ADD_COMMENT': // legacy
$this->executeAddComment($action, $context);
break;
case 'NO_OP':
// Do nothing
break;
default:
log_message('warning', 'Unknown action type: ' . $type);
break;
}
}
/**
* Execute SET_RESULT action
*/
protected function executeSetResult(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('SET_RESULT requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testSiteID = $context['testSiteID'] ?? null;
if ($testSiteID === null && isset($order['TestSiteID'])) {
$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) {
throw new \Exception('SET_RESULT requires testSiteID');
}
// Get the value
if (isset($action['valueExpr']) && is_string($action['valueExpr'])) {
$value = $this->expr->evaluate($action['valueExpr'], $context);
} else {
$value = $action['value'] ?? null;
}
$testSiteCode = $testCode ?? $this->resolveTestSiteCode($testSiteID);
$db = \Config\Database::connect();
// Check if patres row exists
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()
->getRowArray();
if ($patres) {
// Update existing result
$ok = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->update(['Result' => $value]);
} else {
// Insert new result row
$ok = $db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'TestSiteCode' => $testSiteCode,
'Result' => $value,
'CreateDate' => date('Y-m-d H:i:s'),
]);
}
if ($ok === false) {
throw new \Exception('SET_RESULT update/insert failed');
}
}
/**
* Execute INSERT_TEST action - Insert a new test into patres
*/
protected function executeInsertTest(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('INSERT_TEST requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testCode = $action['testCode'] ?? null;
if (empty($testCode)) {
throw new \Exception('INSERT_TEST requires testCode');
}
// Look up TestSiteID from TestSiteCode
$testDefSiteModel = new TestDefSiteModel();
$testSite = $testDefSiteModel->where('TestSiteCode', $testCode)
->where('EndDate', null)
->first();
if (!$testSite || empty($testSite['TestSiteID'])) {
throw new \Exception('INSERT_TEST: Test not found with code: ' . $testCode);
}
$testSiteID = (int) $testSite['TestSiteID'];
$db = \Config\Database::connect();
// Check if test already exists (avoid duplicates)
$existing = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()
->getRow();
if ($existing) {
// Test already exists, skip
return;
}
// Insert new test row
$ok = $db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'TestSiteCode' => $testCode,
'CreateDate' => date('Y-m-d H:i:s'),
]);
if ($ok === false) {
throw new \Exception('INSERT_TEST insert failed');
}
}
/**
* Execute ADD_COMMENT action - Add a comment to the order
*/
protected function executeAddComment(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('ADD_COMMENT requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$comment = $action['comment'] ?? null;
if (empty($comment)) {
throw new \Exception('ADD_COMMENT requires comment');
}
$db = \Config\Database::connect();
// Insert comment into ordercom table
$ok = $db->table('ordercom')->insert([
'InternalOID' => $internalOID,
'Comment' => $comment,
'CreateDate' => date('Y-m-d H:i:s'),
]);
if ($ok === false) {
throw new \Exception('ADD_COMMENT insert failed');
}
}
/**
* Execute TEST_DELETE action - soft delete a test from patres
*/
protected function executeDeleteTest(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('TEST_DELETE requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testCode = $action['testCode'] ?? null;
if (empty($testCode)) {
throw new \Exception('TEST_DELETE requires testCode');
}
$testDefSiteModel = new TestDefSiteModel();
$testSite = $testDefSiteModel->where('TestSiteCode', $testCode)
->where('EndDate', null)
->first();
if (!$testSite || empty($testSite['TestSiteID'])) {
// Unknown test code: no-op
return;
}
$testSiteID = (int) $testSite['TestSiteID'];
$db = \Config\Database::connect();
// Soft delete matching patres row(s)
$db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->update(['DelDate' => date('Y-m-d H:i:s')]);
}
private function resolveTestSiteCode(int $testSiteID): ?string
{
try {
$testDefSiteModel = new TestDefSiteModel();
$row = $testDefSiteModel->where('TestSiteID', $testSiteID)->where('EndDate', null)->first();
return $row['TestSiteCode'] ?? null;
} catch (\Throwable $e) {
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;
}
}
}